An origin-aware animated lightbox. Click a thumbnail and a modal dialog grows out of that exact thumbnail, settles dead-center, then collapses back into it on close. No JavaScript.
dialoganchor()exit animations
<div class="grid w-full max-w-lg grid-cols-2 gap-3 sm:grid-cols-3 [container-type:inline-size] [--gal-cols:2] sm:[--gal-cols:3] [--gal-scale:tan(atan2(calc((100cqw-(var(--gal-cols)-1)*0.75rem)/var(--gal-cols)),min(90vw,40rem)))]"> <!-- Each thumbnail opens its own modal <dialog>, so the page behind is inert while it's open. The panel rests dead-center; its closed and @starting-style frames are pinned to the thumbnail via anchor(), so it grows *out of* the thumbnail on open and collapses back *into* it on close. Each panel is a pure object-cover image box: the collapse scale is computed from the tile's own size (a container-query unit cast to a ratio via tan(atan2())), so it lands on the tile at any viewport, while aspect-ratio (square<->4:3) and border-radius animate the shape, so the photo re-crops without distortion and no opacity fade is needed. max-h-none/max-w-none drop the UA modal clamp — scale shrinks only the paint, not the 40rem layout box, so without this a short viewport crushes that box out of square. closedby="any" closes it (Esc or backdrop click); the dialog is named by aria-label. Without anchor() both frames fall back to center (a plain centered zoom). Images are hotlinked from Unsplash; swap each src/alt for your own. --> <button type="button" command="show-modal" commandfor="gal-photo-1" class="relative isolate aspect-square cursor-pointer overflow-hidden rounded-xl outline-1 -outline-offset-1 outline-foreground/10 transition-shadow duration-300 ease-out hover:ring-2 hover:ring-ring/70 hover:ring-offset-2 hover:ring-offset-background focus-visible:ring-2 focus-visible:ring-ring/70 focus-visible:ring-offset-2 focus-visible:ring-offset-background [anchor-name:--gal-1]" > <img src="https://images.unsplash.com/photo-1759816671674-b0d233903533?auto=format&fit=crop&w=600&q=80" alt="Dawn over the ridge" loading="lazy" decoding="async" class="block relative -z-10 size-full object-cover" /> </button> <dialog id="gal-photo-1" closedby="any" aria-label="Dawn over the ridge" class="fixed top-1/2 left-1/2 w-[min(90vw,40rem)] max-h-none max-w-none aspect-[4/3] scale-[var(--gal-scale)] isolate overflow-hidden rounded-2xl outline-1 -outline-offset-1 outline-foreground/10 bg-popover shadow-2xl [translate:-50%_-50%] [position-anchor:--gal-1] transition-[left,top,scale,aspect-ratio,border-radius,overlay,display] transition-discrete duration-300 ease-out open:scale-100 [&:not([open])]:aspect-square [&:not([open])]:rounded-[3rem] starting:open:aspect-square starting:open:rounded-[3rem] starting:open:scale-[var(--gal-scale)] backdrop:bg-black/0 backdrop:backdrop-blur-none backdrop:transition-all backdrop:transition-discrete backdrop:duration-300 open:backdrop:bg-black/60 open:backdrop:backdrop-blur-xs starting:open:backdrop:bg-black/0 starting:open:backdrop:backdrop-blur-none supports-[anchor-name:--a]:starting:open:[left:anchor(--gal-1_center)] supports-[anchor-name:--a]:starting:open:[top:anchor(--gal-1_center)] supports-[anchor-name:--a]:[&:not([open])]:[left:anchor(--gal-1_center)] supports-[anchor-name:--a]:[&:not([open])]:[top:anchor(--gal-1_center)]" > <img src="https://images.unsplash.com/photo-1759816671674-b0d233903533?auto=format&fit=crop&w=1200&q=80" alt="" loading="lazy" decoding="async" class="absolute inset-0 -z-10 h-full w-full object-cover" /> </dialog> <button type="button" command="show-modal" commandfor="gal-photo-2" class="relative isolate aspect-square cursor-pointer overflow-hidden rounded-xl outline-1 -outline-offset-1 outline-foreground/10 transition-shadow duration-300 ease-out hover:ring-2 hover:ring-ring/70 hover:ring-offset-2 hover:ring-offset-background focus-visible:ring-2 focus-visible:ring-ring/70 focus-visible:ring-offset-2 focus-visible:ring-offset-background [anchor-name:--gal-2]" > <img src="https://images.unsplash.com/photo-1569150159137-fec7ce0a077d?auto=format&fit=crop&w=600&q=80" alt="Harbor at golden hour" loading="lazy" decoding="async" class="block relative -z-10 size-full object-cover" /> </button> <dialog id="gal-photo-2" closedby="any" aria-label="Harbor at golden hour" class="fixed top-1/2 left-1/2 w-[min(90vw,40rem)] max-h-none max-w-none aspect-[4/3] scale-[var(--gal-scale)] isolate overflow-hidden rounded-2xl outline-1 -outline-offset-1 outline-foreground/10 bg-popover shadow-2xl [translate:-50%_-50%] [position-anchor:--gal-2] transition-[left,top,scale,aspect-ratio,border-radius,overlay,display] transition-discrete duration-300 ease-out open:scale-100 [&:not([open])]:aspect-square [&:not([open])]:rounded-[3rem] starting:open:aspect-square starting:open:rounded-[3rem] starting:open:scale-[var(--gal-scale)] backdrop:bg-black/0 backdrop:backdrop-blur-none backdrop:transition-all backdrop:transition-discrete backdrop:duration-300 open:backdrop:bg-black/60 open:backdrop:backdrop-blur-xs starting:open:backdrop:bg-black/0 starting:open:backdrop:backdrop-blur-none supports-[anchor-name:--a]:starting:open:[left:anchor(--gal-2_center)] supports-[anchor-name:--a]:starting:open:[top:anchor(--gal-2_center)] supports-[anchor-name:--a]:[&:not([open])]:[left:anchor(--gal-2_center)] supports-[anchor-name:--a]:[&:not([open])]:[top:anchor(--gal-2_center)]" > <img src="https://images.unsplash.com/photo-1569150159137-fec7ce0a077d?auto=format&fit=crop&w=1200&q=80" alt="" loading="lazy" decoding="async" class="absolute inset-0 -z-10 h-full w-full object-cover" /> </dialog> <button type="button" command="show-modal" commandfor="gal-photo-3" class="relative isolate aspect-square cursor-pointer overflow-hidden rounded-xl outline-1 -outline-offset-1 outline-foreground/10 transition-shadow duration-300 ease-out hover:ring-2 hover:ring-ring/70 hover:ring-offset-2 hover:ring-offset-background focus-visible:ring-2 focus-visible:ring-ring/70 focus-visible:ring-offset-2 focus-visible:ring-offset-background [anchor-name:--gal-3]" > <img src="https://images.unsplash.com/photo-1741276236509-c6f10f4f4c6c?auto=format&fit=crop&w=600&q=80" alt="Fern canopy" loading="lazy" decoding="async" class="block relative -z-10 size-full object-cover" /> </button> <dialog id="gal-photo-3" closedby="any" aria-label="Fern canopy" class="fixed top-1/2 left-1/2 w-[min(90vw,40rem)] max-h-none max-w-none aspect-[4/3] scale-[var(--gal-scale)] isolate overflow-hidden rounded-2xl outline-1 -outline-offset-1 outline-foreground/10 bg-popover shadow-2xl [translate:-50%_-50%] [position-anchor:--gal-3] transition-[left,top,scale,aspect-ratio,border-radius,overlay,display] transition-discrete duration-300 ease-out open:scale-100 [&:not([open])]:aspect-square [&:not([open])]:rounded-[3rem] starting:open:aspect-square starting:open:rounded-[3rem] starting:open:scale-[var(--gal-scale)] backdrop:bg-black/0 backdrop:backdrop-blur-none backdrop:transition-all backdrop:transition-discrete backdrop:duration-300 open:backdrop:bg-black/60 open:backdrop:backdrop-blur-xs starting:open:backdrop:bg-black/0 starting:open:backdrop:backdrop-blur-none supports-[anchor-name:--a]:starting:open:[left:anchor(--gal-3_center)] supports-[anchor-name:--a]:starting:open:[top:anchor(--gal-3_center)] supports-[anchor-name:--a]:[&:not([open])]:[left:anchor(--gal-3_center)] supports-[anchor-name:--a]:[&:not([open])]:[top:anchor(--gal-3_center)]" > <img src="https://images.unsplash.com/photo-1741276236509-c6f10f4f4c6c?auto=format&fit=crop&w=1200&q=80" alt="" loading="lazy" decoding="async" class="absolute inset-0 -z-10 h-full w-full object-cover" /> </dialog> <button type="button" command="show-modal" commandfor="gal-photo-4" class="relative isolate aspect-square cursor-pointer overflow-hidden rounded-xl outline-1 -outline-offset-1 outline-foreground/10 transition-shadow duration-300 ease-out hover:ring-2 hover:ring-ring/70 hover:ring-offset-2 hover:ring-offset-background focus-visible:ring-2 focus-visible:ring-ring/70 focus-visible:ring-offset-2 focus-visible:ring-offset-background [anchor-name:--gal-4]" > <img src="https://images.unsplash.com/photo-1725060555163-c1fd4f569925?auto=format&fit=crop&w=600&q=80" alt="Coastal fog" loading="lazy" decoding="async" class="block relative -z-10 size-full object-cover" /> </button> <dialog id="gal-photo-4" closedby="any" aria-label="Coastal fog" class="fixed top-1/2 left-1/2 w-[min(90vw,40rem)] max-h-none max-w-none aspect-[4/3] scale-[var(--gal-scale)] isolate overflow-hidden rounded-2xl outline-1 -outline-offset-1 outline-foreground/10 bg-popover shadow-2xl [translate:-50%_-50%] [position-anchor:--gal-4] transition-[left,top,scale,aspect-ratio,border-radius,overlay,display] transition-discrete duration-300 ease-out open:scale-100 [&:not([open])]:aspect-square [&:not([open])]:rounded-[3rem] starting:open:aspect-square starting:open:rounded-[3rem] starting:open:scale-[var(--gal-scale)] backdrop:bg-black/0 backdrop:backdrop-blur-none backdrop:transition-all backdrop:transition-discrete backdrop:duration-300 open:backdrop:bg-black/60 open:backdrop:backdrop-blur-xs starting:open:backdrop:bg-black/0 starting:open:backdrop:backdrop-blur-none supports-[anchor-name:--a]:starting:open:[left:anchor(--gal-4_center)] supports-[anchor-name:--a]:starting:open:[top:anchor(--gal-4_center)] supports-[anchor-name:--a]:[&:not([open])]:[left:anchor(--gal-4_center)] supports-[anchor-name:--a]:[&:not([open])]:[top:anchor(--gal-4_center)]" > <img src="https://images.unsplash.com/photo-1725060555163-c1fd4f569925?auto=format&fit=crop&w=1200&q=80" alt="" loading="lazy" decoding="async" class="absolute inset-0 -z-10 h-full w-full object-cover" /> </dialog> <button type="button" command="show-modal" commandfor="gal-photo-5" class="relative isolate aspect-square cursor-pointer overflow-hidden rounded-xl outline-1 -outline-offset-1 outline-foreground/10 transition-shadow duration-300 ease-out hover:ring-2 hover:ring-ring/70 hover:ring-offset-2 hover:ring-offset-background focus-visible:ring-2 focus-visible:ring-ring/70 focus-visible:ring-offset-2 focus-visible:ring-offset-background [anchor-name:--gal-5]" > <img src="https://images.unsplash.com/photo-1769927954284-15e0c9a10add?auto=format&fit=crop&w=600&q=80" alt="Desert bloom" loading="lazy" decoding="async" class="block relative -z-10 size-full object-cover" /> </button> <dialog id="gal-photo-5" closedby="any" aria-label="Desert bloom" class="fixed top-1/2 left-1/2 w-[min(90vw,40rem)] max-h-none max-w-none aspect-[4/3] scale-[var(--gal-scale)] isolate overflow-hidden rounded-2xl outline-1 -outline-offset-1 outline-foreground/10 bg-popover shadow-2xl [translate:-50%_-50%] [position-anchor:--gal-5] transition-[left,top,scale,aspect-ratio,border-radius,overlay,display] transition-discrete duration-300 ease-out open:scale-100 [&:not([open])]:aspect-square [&:not([open])]:rounded-[3rem] starting:open:aspect-square starting:open:rounded-[3rem] starting:open:scale-[var(--gal-scale)] backdrop:bg-black/0 backdrop:backdrop-blur-none backdrop:transition-all backdrop:transition-discrete backdrop:duration-300 open:backdrop:bg-black/60 open:backdrop:backdrop-blur-xs starting:open:backdrop:bg-black/0 starting:open:backdrop:backdrop-blur-none supports-[anchor-name:--a]:starting:open:[left:anchor(--gal-5_center)] supports-[anchor-name:--a]:starting:open:[top:anchor(--gal-5_center)] supports-[anchor-name:--a]:[&:not([open])]:[left:anchor(--gal-5_center)] supports-[anchor-name:--a]:[&:not([open])]:[top:anchor(--gal-5_center)]" > <img src="https://images.unsplash.com/photo-1769927954284-15e0c9a10add?auto=format&fit=crop&w=1200&q=80" alt="" loading="lazy" decoding="async" class="absolute inset-0 -z-10 h-full w-full object-cover" /> </dialog> <button type="button" command="show-modal" commandfor="gal-photo-6" class="relative isolate aspect-square cursor-pointer overflow-hidden rounded-xl outline-1 -outline-offset-1 outline-foreground/10 transition-shadow duration-300 ease-out hover:ring-2 hover:ring-ring/70 hover:ring-offset-2 hover:ring-offset-background focus-visible:ring-2 focus-visible:ring-ring/70 focus-visible:ring-offset-2 focus-visible:ring-offset-background [anchor-name:--gal-6]" > <img src="https://images.unsplash.com/photo-1760546100206-de205df95289?auto=format&fit=crop&w=600&q=80" alt="Night market" loading="lazy" decoding="async" class="block relative -z-10 size-full object-cover" /> </button> <dialog id="gal-photo-6" closedby="any" aria-label="Night market" class="fixed top-1/2 left-1/2 w-[min(90vw,40rem)] max-h-none max-w-none aspect-[4/3] scale-[var(--gal-scale)] isolate overflow-hidden rounded-2xl outline-1 -outline-offset-1 outline-foreground/10 bg-popover shadow-2xl [translate:-50%_-50%] [position-anchor:--gal-6] transition-[left,top,scale,aspect-ratio,border-radius,overlay,display] transition-discrete duration-300 ease-out open:scale-100 [&:not([open])]:aspect-square [&:not([open])]:rounded-[3rem] starting:open:aspect-square starting:open:rounded-[3rem] starting:open:scale-[var(--gal-scale)] backdrop:bg-black/0 backdrop:backdrop-blur-none backdrop:transition-all backdrop:transition-discrete backdrop:duration-300 open:backdrop:bg-black/60 open:backdrop:backdrop-blur-xs starting:open:backdrop:bg-black/0 starting:open:backdrop:backdrop-blur-none supports-[anchor-name:--a]:starting:open:[left:anchor(--gal-6_center)] supports-[anchor-name:--a]:starting:open:[top:anchor(--gal-6_center)] supports-[anchor-name:--a]:[&:not([open])]:[left:anchor(--gal-6_center)] supports-[anchor-name:--a]:[&:not([open])]:[top:anchor(--gal-6_center)]" > <img src="https://images.unsplash.com/photo-1760546100206-de205df95289?auto=format&fit=crop&w=1200&q=80" alt="" loading="lazy" decoding="async" class="absolute inset-0 -z-10 h-full w-full object-cover" /> </dialog></div>
How it works
Each thumbnail is a <button command="show-modal" commandfor="gal-photo-1">; each enlarged view
is its own modal <dialog id="gal-photo-1" closedby="any">. The command opens it with no script,
the modal gives you the top layer, an inert background, focus trapping and Esc-to-close, and
closedby="any" adds click-the-backdrop-to-close — there is no in-panel button.
The panel is one object-cover image box that morphs shape, not just size. Open, it rests
centered (fixed top-1/2 left-1/2, [translate:-50%_-50%], w-[min(90vw,40rem)]) at
open:scale-100, aspect-[4/3], rounded-2xl. Closed, it collapses onto the tile — and the
shrink factor is computed, not a hand-tuned number. The grid is a [container-type:inline-size]
query container, so 100cqw is its own width; [--gal-scale] reconstructs the tile from that
(minus the gaps, divided by [--gal-cols] — 2, then 3 at sm) and casts tile ÷ panel to a
bare ratio with tan(atan2(…)), because calc() refuses to divide a length by a length.
anchor-size() would read the tile directly, but it is barred from transform — hence the
container-query reconstruction. The upshot is [scale:var(--gal-scale)]: the collapsed square
lands on the tile at any container width, not just the design width, alongside aspect-square
and rounded-[3rem]. Animating scale, aspect-ratio and border-radius at once, the photo
grows from a rounded square and re-crops into the 4/3 frame with no stretch and no fade.
The origin comes from anchor().[anchor-name:--gal-1] names the button and
[position-anchor:--gal-1] binds the dialog. The @starting-style entry frame and the closed
frame pin left/top to anchor(--gal-1_center) (behind a supports-[anchor-name:--a]: guard),
so the panel grows out of the thumbnail and back into it while the open state stays centered.
transition-[left,top,scale,aspect-ratio,border-radius,overlay,display] with
transition-discrete (transition-behavior: allow-discrete) animates the travel and keeps
display/overlay alive so the close animates too. The ::backdrop fades from a transparent
base (backdrop:bg-black/0) to open:backdrop:bg-black/60 + backdrop-blur-xs.
The hairline edge is outline-1 -outline-offset-1 outline-foreground/10. The image is
absolute inset-0 and would paint over it, so it takes -z-10 and the dialog is isolate —
that keeps the negative z inside the panel, above its background but under the outline.
Accessibility
The modal <dialog> makes the page inert, traps focus, restores it to the thumbnail on
close, and closes on Esc — native, no focus-trap library. With no visible caption the dialog is
named by aria-label; each trigger is named by its <img alt>.
Fallbacks
Without anchor() both frames fall back to center — a clean centered zoom, minus the origin
(that is why the anchored left/top sit behind supports-[anchor-name:--a]:).
Without invoker commands the trigger does nothing; open the dialog with a small shim or from
your framework. Without discrete transitions it appears and disappears instantly — you lose
the grow, nothing else.
Browser support
Minimum stable version per engine, resolved at build time from MDN's browser-compat-data; Baseline status from the official web-features dataset. Everything degrades gracefully — the “when missing” column is the actual behavior, not a broken page.
An element positioned with the anchor() function falls back to its normal position, so an indicator that tracks another element simply does not move; the underlying content stays fully usable.
Elements appear and disappear instantly. Nothing breaks — you just lose the animation.
† @supports cannot test the anchor() function inside a value, so the badge checks anchor-name: --a — anchor-name and anchor() shipped together in every engine.
† @supports cannot test at-rules, so the badge checks transition-behavior: allow-discrete, which co-shipped with @starting-style in every engine.
‡ HTML feature — @supports can only test CSS, and we ship no JS to detect it, so it gets no live badge.