Composition
Slides + Zoom
Prefer Slides + Slide for a swipeable snap carousel wired to the active index. Nearly every docs demo and example uses
this layout. Slide boxes stay mounted; preload gates heavy Item content.
- Nesting rule: put
Itemand Zoom inside eachSlide. Pull transforms the item, not the snap box. - User flings set an internal scroll lock so pull-to-dismiss, zoom chrome, and discrete prev/next do not fight the carousel mid-gesture.
- Scroll-driven index changes report
onValueChangewithsource: 'scroll', see NavigationSource. - The Item snippet’s
activeis the current index (drive expensive work here).visiblemeans the item is in the preload window: use it for lightweight prefetch, not video autoplay.
<script lang="ts"> import { Lightbox } from '@ramka/svelte'; let { items }: { items: { id: string; src: string; alt: string }[] } = $props();</script><Lightbox.Root> <Lightbox.Portal> <Lightbox.Backdrop /> <Lightbox.Content> <Lightbox.Slides preload={1}> {#each items as item, i (item.id)} <Lightbox.Slide> <Lightbox.Item index={i}> <Lightbox.Zoom> <Lightbox.Media> <img src={item.src} alt={item.alt} /> </Lightbox.Media> </Lightbox.Zoom> </Lightbox.Item> </Lightbox.Slide> {/each} </Lightbox.Slides> </Lightbox.Content> </Lightbox.Portal></Lightbox.Root>[data-ramka-slide] { flex: 0 0 100%; min-width: 100%;}Media layout
ramka is cosmetically unstyled (colors and chrome are yours), but Media may apply structural fit-box styles through width/height so the morph target has a correct aspect before pixels load. Slide width is your CSS too: Slide only bakes in snap alignment (scroll-snap-align: center, scroll-snap-stop: always); you size each snap box. For a
full-bleed carousel, use flex: 0 0 100% and min-width: 100% on each slide (see the snippet above). Snug,
variable-width tracks use a different width formula, see the Default preset. Item and Zoom fill the slide
content box; size the stack so when Zoom scales past 1× the image can cover the viewport and
is not clipped by a shorter ancestor.
Content: fullscreen surface (fixed inset-0or a full dialog)Slides:flex-1(or an equivalent stretch)Slide:flex: 0 0 100%; min-width: 100%for a full-bleed track;h-fullfor height; put chrome and safe-area insets here as padding (not on Zoom)Item: already fills the Slide (width/height: 100%)Zoom: already fills and centers (structural)Media: sized fit-box (morph and placeholder host);<img>or<video>inside
Keep Close, Counter, Caption, and thumbnails as absolutely positioned siblings of Slides (see Chrome below), not in-flow above or below Zoom. Resting insets belong on Slide padding and safe-area so zoomed media can still paint into that padding under chrome. Gesture details live in Gestures.
Set HTML width and height on the
destination <img> (from known pixel dimensions or CMS metadata) so the
box and aspect ratio exist before pixels load. That avoids layout shift, keeps the
morph target sized on slow networks, and prevents an unloaded image from collapsing to 0×0
(which can skip or weaken the open morph). When dimensions are unknown, see URL-synced state. For mixed
media (photos plus WebGL) with onBeforeOpen preheat, see 3D models.
To fill the maximum available space without distorting the source aspect ratio, nest Media
under Lightbox.Zoom (a size container for cqw/cqh) and pass width/height to Lightbox.Media. Media applies the inscribed
fit-box (min(100cqw, 100cqh × w/h) + aspect-ratio: w / h). Or put HTML width/height on the child image and
omit the Media props: Media derives the box. When the box matches the source aspect, child object-fit is a no-op and Media’s grid stretch fills the box. Put border-radius
on Media (the morph target). Leave chrome outside Media.
<script lang="ts"> import { Lightbox } from '@ramka/svelte'; let { src, alt, width, height }: { src: string; alt: string; width: number; height: number } = $props();</script><Lightbox.Root> <Lightbox.Portal> <Lightbox.Backdrop /> <Lightbox.Content class="fixed inset-0 flex flex-col"> <Lightbox.Slides class="flex-1"> <Lightbox.Slide class="h-full p-8"> <Lightbox.Item index={0}> <Lightbox.Zoom> <Lightbox.Media {width} {height}> <img {width} {height} {src} {alt} /> </Lightbox.Media> </Lightbox.Zoom> </Lightbox.Item> </Lightbox.Slide> </Lightbox.Slides> </Lightbox.Content> </Lightbox.Portal></Lightbox.Root>Live compositions: Default preset, Twitter preset, Stories preset, Photos app.
Stage (split layouts)
Use Lightbox.Stage when Content holds more than the media pane, for example a thread sidebar beside the gallery. Stage is the media interaction surface: trackpad wheel pull-to-dismiss, Zoom pan cover geometry, and Left arrow Right arrow / Home / End gallery navigation are scoped to it. Content remains the dialog shell (focus trap, Esc, zoom shortcuts). Place side panels as siblings of Stage under Content so they keep native scroll and their own keyboard behavior. Without a Stage, Content itself is the stage (default full-bleed viewers need no change).
<Lightbox.Content aria-label="Post"> <Lightbox.Stage class="media-pane"> <Lightbox.Close aria-label="Close" /> <Lightbox.Slides><!-- media --></Lightbox.Slides> <Lightbox.Previous aria-label="Previous" /> <Lightbox.Next aria-label="Next" /> </Lightbox.Stage> <aside><!-- thread / details --></aside></Lightbox.Content>Attach Stage’s morphRef when the open and close morph should
capture the Stage surface (or a descendant) instead of the active item: a 9:16 stories frame,
the slides track, or a custom bezel. Decode and the thumbnail placeholder stay on the item. In
Svelte, morphRef is an attachment: {@attach morphRef}.
<Lightbox.Stage> {#snippet children({ morphRef })} <div class="frame" {@attach morphRef}> <Lightbox.Slides><!-- media --></Lightbox.Slides> </div> {/snippet}</Lightbox.Stage>Live examples: Twitter preset, Stories preset.
Chrome
The site default viewer is Slides + Zoom with Counter, Close, ZoomIn and ZoomOut, Caption, and
a desktop ThumbnailStrip (scroll-linked midline
track) as siblings inside Content. Midline
selection chrome goes in the strip’s indicator snippet. Thumbnails register by index like Triggers. Put Backdrop and Content in Portal. Stacking follows the usual dialog
pattern: isolation: isolate on the app layout root and, with keepMounted, your own z-index on the
Portal host (for example class="relative z-[1]"). Inside the
portal, document order keeps Backdrop under Content.
Thumbnails go inside a ThumbnailStripTrack, the role="tablist" that the strip moves. Label the track, not the strip.
<script lang="ts"> import { Lightbox } from '@ramka/svelte'; let { items }: { items: { id: string; src: string; thumb: string; alt: string; caption: string }[] } = $props();</script><Lightbox.Root> <Lightbox.Portal> <Lightbox.Backdrop /> <Lightbox.Content> <Lightbox.Slides> {#each items as item, i (item.id)} <Lightbox.Slide> <Lightbox.Item index={i} caption={item.caption}> <Lightbox.Zoom> <Lightbox.Media> <img src={item.src} alt={item.alt} /> </Lightbox.Media> </Lightbox.Zoom> </Lightbox.Item> </Lightbox.Slide> {/each} </Lightbox.Slides> <Lightbox.Counter /> <Lightbox.ZoomOut aria-label="Zoom out" /> <Lightbox.ZoomIn aria-label="Zoom in" /> <Lightbox.Close aria-label="Close" /> <Lightbox.Caption /> <Lightbox.ThumbnailStrip> <Lightbox.ThumbnailStripTrack aria-label="Photo thumbnails"> {#each items as item, i (item.id)} <Lightbox.Thumbnail index={i}> <img src={item.thumb} alt="" /> </Lightbox.Thumbnail> {/each} </Lightbox.ThumbnailStripTrack> {#snippet indicator()} <span aria-hidden="true"></span> {/snippet} </Lightbox.ThumbnailStrip> </Lightbox.Content> </Lightbox.Portal></Lightbox.Root>Packaged as a skin: Default preset.
Dynamic item count
Items register on mount, and the item count comes from the registered items. Indices do not
need to be contiguous, but Counter, loop edges, and preload distance all use that count.
Prefer dense 0…n-1 indexes unless you leave holes on purpose. Before the
viewer has mounted (for a counter outside it, say), Root’s count prop
stands in.