unscripted/ui

Gallery

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

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.

FeatureChromeEdgeFirefoxSafariWhen missing
<dialog> elementBaseline · widely available37799815.4Baseline, widely available. Modal behavior, focus handling and Esc-to-close are native.
Invoker commands (commandfor / command)Baseline 2025 · newly available13513514426.2Buttons with commandfor do nothing. Until Baseline reaches your audience, open the dialog with a 3-line shim or from your framework.
closedby="any" (light dismiss)Limited availability134134141Esc still closes the dialog (platform default); clicking the backdrop does not.
anchor() function positioningLimited availability12512514726An 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.
Entry/exit animations (allow-discrete + @starting-style)Baseline 2024 · newly available11711712917.5Elements 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.