View transitions
Morph refs
Morph is on by default (viewTransition on Root). Pass viewTransition={false} to opt out. Recommended destination shape: Lightbox.Media around the <img> or <video> (a
sized fit-box that is both morph target and placeholder host). On Trigger, the first img inside is the morph source on its own. To be explicit, take the
refs from the children snippet; both are attachments:
imageRef: attach to the Trigger thumb<img>whose pixels bridge the morph. Media finds the destination image (or video poster) by itself. Open reuses the already-decoded bitmap so the transition doesn’t flash empty space.morphRef: optional escape hatch when morph bounds should differ from Media or the image (tile with overlay, swatch + label).
Nested components can read the same refs from context with getTrigger() and getItem() instead of threading
the snippet argument. Use awaitImageDecode on Root when you want a short wait for the destination
image before the morph starts (default cap about 200ms).
<script lang="ts"> import { Lightbox } from '@ramka/svelte'; let { thumb, src }: { thumb: string; src: string } = $props();</script><Lightbox.Root morphTo="active"> <Lightbox.Trigger index={0}> {#snippet children({ imageRef })} <img {@attach imageRef} src={thumb} alt="" /> {/snippet} </Lightbox.Trigger> <Lightbox.Portal> <Lightbox.Backdrop /> <Lightbox.Content> <Lightbox.Slides> <Lightbox.Slide> <Lightbox.Item index={0}> <Lightbox.Zoom> <Lightbox.Media> <img {src} alt="" /> </Lightbox.Media> </Lightbox.Zoom> </Lightbox.Item> </Lightbox.Slide> </Lightbox.Slides> </Lightbox.Content> </Lightbox.Portal></Lightbox.Root>Source aspect
The morph snapshots whatever pixels are on the trigger <img> and interpolates them into the destination Media box. That only looks like one photo when both sides
share the same intrinsic aspect ratio: the same file, or srcset candidates of that file. A
square tile is fine; crop it with CSS (object-fit: cover), not by
baking w=h&fit=crop into a different image.
A CDN-cropped 1∶1 thumb against a 3∶2 lightbox image will reframe mid-animation: ramka paints the square thumb into the landscape box, then the full file lands with a different crop. Element boxes may differ. Intrinsic aspect must not.
These demos delay the lightbox image by about 500ms so the handoff is visible on every open.
<script lang="ts"> import { Lightbox } from '@ramka/svelte'; let { photo }: { photo: { src: string; width: number; height: number } } = $props();</script><Lightbox.Root> <Lightbox.Trigger index={0}> {#snippet children({ imageRef })} <img {@attach imageRef} src={photo.src} alt="Mountain lake" class="aspect-square size-full object-cover" /> {/snippet} </Lightbox.Trigger> <Lightbox.Portal> <Lightbox.Backdrop /> <Lightbox.Content> <Lightbox.Slides> <Lightbox.Slide> <Lightbox.Item index={0}> <Lightbox.Zoom> <Lightbox.Media width={photo.width} height={photo.height}> <img src={photo.src} width={photo.width} height={photo.height} alt="Mountain lake" /> </Lightbox.Media> </Lightbox.Zoom> </Lightbox.Item> </Lightbox.Slide> </Lightbox.Slides> </Lightbox.Content> </Lightbox.Portal></Lightbox.Root><script lang="ts"> import { Lightbox } from '@ramka/svelte'; let { photo, squareThumb }: { photo: { src: string; width: number; height: number }; squareThumb: string } = $props();</script><Lightbox.Root> <Lightbox.Trigger index={0}> {#snippet children({ imageRef })} <!-- ✗ a 1∶1 file; Media below is the full 3∶2 photo --> <img {@attach imageRef} src={squareThumb} alt="Mountain lake" class="size-full object-cover" /> {/snippet} </Lightbox.Trigger> <Lightbox.Portal> <Lightbox.Backdrop /> <Lightbox.Content> <Lightbox.Slides> <Lightbox.Slide> <Lightbox.Item index={0}> <Lightbox.Zoom> <Lightbox.Media width={photo.width} height={photo.height}> <img src={photo.src} width={photo.width} height={photo.height} alt="Mountain lake" /> </Lightbox.Media> </Lightbox.Zoom> </Lightbox.Item> </Lightbox.Slide> </Lightbox.Slides> </Lightbox.Content> </Lightbox.Portal></Lightbox.Root>Trigger crossfade
Controls whether the morph dissolves between trigger and item snapshots or hard-cuts geometry only.
- Omit (default): hard-cut. Best when trigger and item show the same picture at the same intrinsic aspect (a CSS
object-coverwindow or a smaller srcset is fine; a CDN-baked crop that changes the aspect is not, see Source aspect). Leavesdata-ramka-crossfadeunset on<html>during the transition. both: dissolve on open and close (stack → single photo, +N overlay tile).open/close: dissolve in one direction only (poster → live video often wantsclose).
Keep trigger and item images on object-fit: cover (or the same
fit) so crops stay coherent. Put border-radius on the morph target itself: the library
interpolates --lightbox-morph-border-radius-from/to on <html>.
| Value | Use when |
|---|---|
undefined | Same picture, same intrinsic aspect ratio. Element boxes may differ |
"both" | Trigger ≠ item both ways: stacks, +N overlays, swatches |
"close" | Open frames match, close doesn’t: poster → playing video |
"open" | Rare inverse of close |
Omit: same picture
Same picture and intrinsic aspect ratio; element boxes may differ. Both sides use object-fit: cover. Below: square thumbs → landscape viewer from 3∶2
sources.
crossfade="both": +N tile
Open dissolves the overlay +N tile into a clean photo. Close from a photo without a thumb uses morphTo="closest".
crossfade="both": any element
Morph sources don’t have to be images: dissolve initials chips into portraits so letters don’t stretch.
- Ava Chen
- Jules Okonkwo
- Sam Rivera
- Mei Laurent
crossfade="close": poster → video
Open hard-cuts into the matching poster frame; close dissolves the live video back to the poster.
<script lang="ts"> import { Lightbox } from '@ramka/svelte'; let { photo, extras }: { photo: string; extras: number } = $props();</script><Lightbox.Root morphTo="closest"> <Lightbox.Trigger index={0} crossfade="both"> {#snippet children({ imageRef })} <div class="relative"> <img {@attach imageRef} src={photo} alt="" /> <span>+{extras}</span> </div> {/snippet} </Lightbox.Trigger> <Lightbox.Portal> <Lightbox.Backdrop /> <Lightbox.Content> <Lightbox.Slides> <Lightbox.Slide> <Lightbox.Item index={0}> {#snippet children({ imageRef })} <img {@attach imageRef} src={photo} alt="" /> {/snippet} </Lightbox.Item> </Lightbox.Slide> </Lightbox.Slides> </Lightbox.Content> </Lightbox.Portal></Lightbox.Root>Root morphTo
Picks which trigger receives the close morph:
active(default): the trigger for the current active index. No morph if that index has no trigger.origin: always the trigger that opened the lightbox (one tile opens many items).closest: the active index if registered, otherwise the nearest registered trigger.
morphTo="active"
Default. Close morphs to the trigger matching the active index. All five images have triggers.
morphTo="origin"
Always morph back to the trigger that opened the lightbox (one tile, many items).
morphTo="closest"
Active trigger if registered, otherwise the nearest one. Useful for +N grids (only the first 3 have triggers below).
<script lang="ts"> import { Lightbox } from '@ramka/svelte';</script><Lightbox.Root morphTo="closest"> <Lightbox.Trigger index={0}><!-- first thumb --></Lightbox.Trigger> <Lightbox.Trigger index={1}><!-- +N tile --></Lightbox.Trigger> <!-- items 2…n close to the nearest trigger --> <Lightbox.Portal> <Lightbox.Backdrop /> <Lightbox.Content> <Lightbox.Slides> <Lightbox.Slide><Lightbox.Item index={0} /></Lightbox.Slide> <Lightbox.Slide><Lightbox.Item index={1} /></Lightbox.Slide> <Lightbox.Slide><Lightbox.Item index={2} /></Lightbox.Slide> </Lightbox.Slides> </Lightbox.Content> </Lightbox.Portal></Lightbox.Root>Root scrollTriggerIntoView
When the page scrolls while the lightbox is open, the close morph can land off-screen. Use scrollTriggerIntoView to keep the target trigger in view:
onChange: scroll to the active trigger as the index changes (preemptive).onOpenComplete: reposition once open settles (invisible under the overlay;instantrecommended).onClose: scroll just before the close morph (visible jump at close start).
Pass one option or an array. Each option also accepts block / inline alignment (default nearest):
use inline: 'center' for horizontal snap strips with variable-width
tiles. Pair with morphTo so the scrolled element is the same one that receives the morph.
<Lightbox.Root scrollTriggerIntoView={[ { type: 'onChange', behavior: 'instant' }, { type: 'onOpenComplete', behavior: 'instant' } ]}> <!-- triggers + content --></Lightbox.Root>Programmatic open
Prefer createHandle(): open(index, { triggerId }) associates a Trigger that declared
that id so the open morph can use that element. Without a registered
trigger (or triggerId), the open falls back to the fade path. Trigger clicks
still morph. Demo: State & lifecycle: Imperative API.
Styling
You write zero view-transition CSS: the library injects the structural ::view-transition-* rules itself (see Injected CSS) and writes document hooks during a
morph so your own skin CSS can react, for example to keep Backdrop and Content fades on the
morph’s clock. Attribute and CSS variable tables live under Document (view transitions). Hooks:
data-ramka-view-transition:opening|closingdata-ramka-crossfade: dissolve path (present when Triggercrossfadeis active for this phase; absent for hard-cut)--lightbox-morph-border-radius-from/to,--lightbox-morph-old/new-opacitydata-ramka-vt-preset,--lightbox-vt-duration,--lightbox-vt-easing,--lightbox-vt-crossfade-close-easing,--lightbox-vt-root-easing: the active timing preset, written by the library (see Timing presets)- Trigger
data-morph-target/ Itemdata-skip-fade: hide the live morph source and suppress entrance fades on the destination while the transition layer is up
Motion splits into timing (a preset picked on Root, see below) and structure (injected by the library). Your CSS only handles cosmetics, and reads the library-written variables where it wants to stay in sync.
Timing presets
Morph duration and easing are chosen with the viewTransition prop
on Root: pass a preset name instead of true:
"default": 360ms, sheet curve with a tamed launch (whattrueruns)"snappy": 340ms, hotter launch; reads quicker"relaxed": 400ms, softer launch and longer settle"spring": 450ms, critically damped spring; Photos-style settle
<Lightbox.Root viewTransition="spring"><!-- … --></Lightbox.Root>Timing is preset-based rather than free-form CSS on purpose: each preset is vetted on-device
to stay smooth and skip-safe on iOS Safari (including Low Power Mode) and Android, where
arbitrary curves stutter or teleport. The library publishes the preset timing as --lightbox-vt-duration / --lightbox-vt-easing / --lightbox-vt-root-easing in two places: always on the Portal host, and,
together with data-ramka-vt-preset, on documentElement for the duration of a morph. Read those variables in your own CSS as the skin’s single motion clock
(for example transition: opacity var(--lightbox-vt-duration, 360ms) ease-out on
Backdrop and Content): during a morph the fades stay in lockstep with it, and on the non-morph paths
(unsupported browsers, reduced motion, low-end fallback) they still run on the preset’s timing.
Do not set the variables yourself.
Injected CSS
On first Root mount the library injects one stylesheet with everything a morph structurally needs: no copy-paste block to maintain, and several skins on one page can never fight over it.
- Name isolation:
startViewTransitionsnapshots the whole document, so any page element with its ownview-transition-namewould paint over the lightbox. Every name is cleared whiledata-ramka-view-transitionis set; the morph target re-applies its name inline with!importantand stays the only captured group. - Morph group: clipping and border-radius interpolation (put radius on the
morph target element; the library writes
--lightbox-morph-border-radius-from/to), timed by the active preset. - Snapshots: hard-cut by default (
object-fit: cover, no browser dissolve). When Triggercrossfadesetsdata-ramka-crossfade, the dissolve path (browser fade,plus-lighter,object-fit: fill) runs on the same preset clock, on a shorter window so the outgoing snapshot is gone before the size settles. - Root snapshot: the page-behind crossfade, kept as a single root
group (promoting Backdrop or Content into named groups tanks Safari). Fade scrim and chrome
with live
opacitytransitions instead. - Morph-target hide:
[data-morph-target]getsopacity: 0with a 200ms fade-back (disabled during the transition so it can’t be captured mid-flight), so the live trigger never shows doubled under the morphing snapshot. - Thumb-jump crossfade: the element-scoped Slides transition started when a
ThumbnailStrip thumbnail jumps several slides, tunable with
--lightbox-select-vt-duration/easingon Slides.
Everything except name isolation is gated under prefers-reduced-motion: no-preference. The stylesheet is inserted at the start of <head>, so your own stylesheets cascade after it:
overriding an injected rule takes an ordinary declaration of equal specificity, no !important needed.
Lifecycle timing for your own CSS: Content and Backdrop stay mounted through exit animations;
while a morph open is preparing they may be visibility: hidden so
the morph layer owns the pixels. Prefer onOpenChangeComplete for post-motion work, see State & lifecycle: Lifecycle callbacks.