Lightbox
A full-screen viewer for images, video and iframe embeds, with zoom and pan, swipe navigation, a thumbnail strip, captions and an optional slideshow. The in-page gallery stays in the document flow so search engines still index it.
Basic Usage
Pass a slides array. Without a trigger snippet,
a responsive thumbnail grid is rendered for you and clicking an entry opens the viewer at that
slide.
<script lang="ts">
import { Lightbox } from 'sv5ui';
import type { LightboxSlide } from 'sv5ui';
const slides: LightboxSlide[] = [
{
src: '/photos/harbour-full.jpg',
thumb: '/photos/harbour-thumb.jpg',
alt: 'Fishing boats at dawn',
title: 'Harbour at dawn',
description: 'Shot on a 35mm lens, 1/250s',
width: 1600,
height: 1000
},
{ src: '/photos/dunes-full.jpg', alt: 'Wind-carved dunes' },
{ src: '/photos/pines-full.jpg', alt: 'Pine forest in fog' }
];
</script>
<!-- Without a trigger snippet, Lightbox renders a responsive
thumbnail grid from slides and opens on click. -->
<Lightbox {slides} />Custom Trigger
The trigger snippet receives the slides and an open(index) function. Anything works: a masonry grid, a hero image, or a single button.
<!-- The trigger snippet replaces the default grid. Its content
lives in the normal document flow, so crawlers index the
images and the page has no layout shift on open. -->
<Lightbox {slides}>
{#snippet trigger({ slides, open })}
<div class="flex gap-3">
{#each slides as slide, i (slide.src)}
<button onclick={() => open(i)}>
<img src={slide.thumb ?? slide.src} alt={slide.alt} width={slide.width} height={slide.height} />
</button>
{/each}
</div>
{/snippet}
</Lightbox>
<!-- A single "View gallery" button works just as well -->
<Lightbox {slides}>
{#snippet trigger({ open })}
<Button label="View gallery" onclick={() => open(0)} />
{/snippet}
</Lightbox>Media Types
Set type per slide. Video slides render a native <video> with controls, iframe slides embed anything. Zoom, rotate and download only apply where they
make sense, so the toolbar adapts as you move between slides.
const slides: LightboxSlide[] = [
// Images are the default and support zoom, pan and rotate
{ src: '/photos/coast.jpg', alt: 'Rocky coastline' },
// Video slides render a native <video> with controls.
// poster shows before playback, attrs is forwarded to the element.
{
type: 'video',
src: '/media/flower.mp4',
poster: '/media/flower-poster.jpg',
alt: 'Time-lapse of a flower opening',
attrs: { loop: true, muted: true }
},
// iframe slides embed anything: maps, YouTube, Vimeo, 3D viewers.
// The download control is hidden automatically for them.
{
type: 'iframe',
src: 'https://www.openstreetmap.org/export/embed.html?bbox=2.25,48.83,2.42,48.90',
alt: 'Map of central Paris',
attrs: { allow: 'fullscreen' }
}
];
<Lightbox {slides} />Captions
A slide's title and description fill the caption bar. Hide it with caption={false}, or replace it with the captionSlot snippet.
<!-- title and description feed the caption bar -->
const slides: LightboxSlide[] = [
{
src: '/photos/dunes.jpg',
alt: 'Wind-carved dunes',
title: 'Erg Chebbi',
description: 'Morocco, 2025'
}
];
<Lightbox {slides} />
<!-- Hide the bar entirely -->
<Lightbox {slides} caption={false} />
<!-- Or replace it. Note the prop is named captionSlot,
because caption is already the boolean toggle. -->
<Lightbox {slides}>
{#snippet captionSlot({ slide, index, total })}
<p>{index + 1} of {total}: {slide.title}</p>
{/snippet}
</Lightbox>Chrome
thumbnails, counter and arrows are
independent toggles. All three hide themselves automatically when there is only one slide, and
the strip also hides on small screens.
<!-- Every piece of chrome is an independent toggle. Each one is
also hidden automatically when there is a single slide. -->
<Lightbox
{slides}
thumbnails={false}
counter={false}
arrows={false}
/>
<!-- Custom thumbnails in the strip -->
<Lightbox {slides}>
{#snippet thumbnail({ slide, active, select })}
<button onclick={select} class={active ? 'ring-2' : ''}>
<img src={slide.thumb ?? slide.src} alt="" />
</button>
{/snippet}
</Lightbox>Zoom
Image slides support wheel, double click and pinch zoom, then drag to pan. Tune the ceiling
with maxScale and the increment with zoomStep, or disable it with zoom={false}, which also removes the zoom buttons.
<!-- Zoom applies to image slides: wheel, double click, pinch,
and drag to pan once zoomed in. -->
<Lightbox {slides} maxScale={8} zoomStep={0.25} />
<!-- Turn it off. The zoom toolbar buttons disappear with it. -->
<Lightbox {slides} zoom={false} />Slideshow
Pass slideshow for a 4 second autoplay, or an options object to change the delay and start playing on open.
<!-- true uses the defaults: 4000ms, paused on open -->
<Lightbox {slides} slideshow />
<!-- Or configure it -->
<Lightbox {slides} slideshow={{ delay: 2500, playOnOpen: true }} />
<!-- The slideshow control only appears when there is more
than one slide. Toggle it from code with the api. -->
<Button label="Play" onclick={() => api?.toggleSlideshow()} />Toolbar
Pass an array to toolbar to pick and order the controls, false to hide it, and use toolbarExtra to append your own.
<!-- Default order: zoomOut, zoomReset, zoomIn, rotate,
slideshow, fullscreen, download, close -->
<Lightbox {slides} />
<!-- Pass an array to show only some controls, in your order -->
<Lightbox {slides} toolbar={['fullscreen', 'download', 'close']} />
<!-- Hide the toolbar entirely (the counter still renders) -->
<Lightbox {slides} toolbar={false} />
<!-- Append your own controls before the close button -->
<Lightbox {slides}>
{#snippet toolbarExtra()}
<Button icon="lucide:heart" variant="ghost" onclick={favourite} />
{/snippet}
</Lightbox>
<!-- Swap any icon -->
<Lightbox {slides} icons={{ close: 'lucide:circle-x', next: 'lucide:arrow-right' }} />Download
The download control fetches src as a blob so cross-origin media saves correctly, falling back to opening a new tab when CORS
blocks the request. Point a slide at a different file with download, or hide the control with download: false. It is hidden on iframe slides automatically.
const slides: LightboxSlide[] = [
// Default: downloads src. It is fetched as a blob so cross-origin
// media saves correctly, falling back to opening a new tab when
// CORS blocks the fetch.
{ src: '/photos/preview.jpg', alt: 'Preview' },
// Point the download at a different file, e.g. the full-resolution original
{ src: '/photos/preview.jpg', alt: 'Preview', download: '/photos/original.tif' },
// Or remove the control for this slide
{ src: '/photos/watermarked.jpg', alt: 'Sample', download: false }
];Transitions
Choose between a cross-fade, a scale-in, or no animation at all. Every option respects prefers-reduced-motion.
scale
none
<Lightbox {slides} transition="fade" /> <!-- default -->
<Lightbox {slides} transition="scale" />
<Lightbox {slides} transition="none" /> <!-- or transition={false} -->
<!-- All transitions respect prefers-reduced-motion -->Imperative API
Bind api to open the viewer from anywhere, or use bind:open and bind:index to drive it from state.
Active index: 0
<script lang="ts">
import { Lightbox, Button } from 'sv5ui';
import type { LightboxApi } from 'sv5ui';
let api = $state<LightboxApi>();
let open = $state(false);
let index = $state(0);
</script>
<div class="flex gap-2">
<Button label="Open at slide 3" onclick={() => api?.open(2)} />
<Button label="Previous" onclick={() => api?.prev()} />
<Button label="Next" onclick={() => api?.next()} />
</div>
<!-- bind:open and bind:index work alongside the api -->
<Lightbox
{slides}
bind:api
bind:open
bind:index
onIndexChange={(i) => console.log('now showing', i)}
/>
<!-- api also exposes: close, goTo, zoomIn, zoomOut, resetZoom,
rotate, toggleSlideshow, and the readonly index, scale
and isOpen values. -->Behaviour
Turn off wrap-around with loop={false}, or require an explicit close with dismissible={false}. Focus trapping and scroll locking are forwarded to the underlying dialog primitive.
<!-- Stop wrapping around at the ends -->
<Lightbox {slides} loop={false} />
<!-- Require an explicit close: Escape and backdrop clicks do nothing -->
<Lightbox {slides} dismissible={false} />
<!-- Focus and scroll handling is forwarded to the dialog primitive -->
<Lightbox
{slides}
trapFocus
preventScroll
onOpenChangeComplete={(open) => !open && restoreFocus()}
/>LightboxSlide
Shape of each entry in the slides array.
| Field | Type | Default |
|---|---|---|
src | string | - |
alt | string | - |
type | 'image' | 'video' | 'iframe' | 'image' |
title | string | - |
description | string | - |
thumb | string | src |
srcset | string | - |
sizes | string | - |
width | number | - |
height | number | - |
download | string | false | - |
poster | string | - |
attrs | Record<string, unknown> | - |
LightboxApi
Methods and readonly state exposed via bind:api.
| Member | Type |
|---|---|
open | (index?: number) => void |
close | () => void |
next / prev | () => void |
goTo | (index: number) => void |
zoomIn / zoomOut | () => void |
resetZoom | () => void |
rotate | () => void |
toggleSlideshow | () => void |
index | readonly number |
scale | readonly number |
isOpen | readonly boolean |
Snippets
| Snippet | Description |
|---|---|
trigger | In-page gallery renderer, receives slides and open(). Replaces the default thumbnail grid |
slide | Custom media renderer, receives slide, index, active and scale |
thumbnail | Custom thumbnail in the strip, receives slide, index, active and select() |
captionSlot | Custom caption, receives slide, index and total |
toolbarExtra | Extra controls appended to the toolbar, before the close button |
UI Slots
Use the ui prop to override classes.
| Slot | Description |
|---|---|
gallery / galleryItem / galleryImage | The in-page thumbnail grid rendered when no trigger snippet is passed |
overlay / content | The backdrop and the full-screen viewer surface |
toolbar / control / counter / toolbarSpacer | The top bar, its buttons and the slide counter |
stage / track / slide | The viewport, the swipeable track and each slide wrapper |
image / media | The image element and the video or iframe element |
arrow / arrowPrev / arrowNext | The previous and next navigation buttons |
caption / captionTitle / captionDescription | The caption bar and its text |
thumbnails / thumbnail / thumbnailImage | The thumbnail strip and its entries |
spinner | The loading indicator shown while media loads |
Props
| Prop | Type | Default |
|---|---|---|
slides | LightboxSlide[] | - |
open | boolean | false |
index | number | 0 |
api | LightboxApi | - |
onOpenChange | (open: boolean) => void | - |
onIndexChange | (index: number) => void | - |
loop | boolean | true |
dismissible | boolean | true |
zoom | boolean | true |
maxScale | number | 5 |
zoomStep | number | 0.5 |
slideshow | boolean | { delay, playOnOpen } | false |
thumbnails | boolean | true |
counter | boolean | true |
caption | boolean | true |
arrows | boolean | true |
toolbar | boolean | LightboxToolbarItem[] | true |
transition | 'fade' | 'scale' | 'none' | boolean | 'fade' |
icons | LightboxIcons | - |
trapFocus | boolean | true |
preventScroll | boolean | true |
class | string | - |
ui | Record<Slot, Class> | - |