Overlay

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.

FieldTypeDefault
srcstring-
altstring-
type'image' | 'video' | 'iframe''image'
titlestring-
descriptionstring-
thumbstringsrc
srcsetstring-
sizesstring-
widthnumber-
heightnumber-
downloadstring | false-
posterstring-
attrsRecord<string, unknown>-

LightboxApi

Methods and readonly state exposed via bind:api.

MemberType
open(index?: number) => void
close() => void
next / prev() => void
goTo(index: number) => void
zoomIn / zoomOut() => void
resetZoom() => void
rotate() => void
toggleSlideshow() => void
indexreadonly number
scalereadonly number
isOpenreadonly boolean

Snippets

SnippetDescription
triggerIn-page gallery renderer, receives slides and open(). Replaces the default thumbnail grid
slideCustom media renderer, receives slide, index, active and scale
thumbnailCustom thumbnail in the strip, receives slide, index, active and select()
captionSlotCustom caption, receives slide, index and total
toolbarExtraExtra controls appended to the toolbar, before the close button

UI Slots

Use the ui prop to override classes.

SlotDescription
gallery / galleryItem / galleryImageThe in-page thumbnail grid rendered when no trigger snippet is passed
overlay / contentThe backdrop and the full-screen viewer surface
toolbar / control / counter / toolbarSpacerThe top bar, its buttons and the slide counter
stage / track / slideThe viewport, the swipeable track and each slide wrapper
image / mediaThe image element and the video or iframe element
arrow / arrowPrev / arrowNextThe previous and next navigation buttons
caption / captionTitle / captionDescriptionThe caption bar and its text
thumbnails / thumbnail / thumbnailImageThe thumbnail strip and its entries
spinnerThe loading indicator shown while media loads

Props

PropTypeDefault
slidesLightboxSlide[]-
openbooleanfalse
indexnumber0
apiLightboxApi-
onOpenChange(open: boolean) => void-
onIndexChange(index: number) => void-
loopbooleantrue
dismissiblebooleantrue
zoombooleantrue
maxScalenumber5
zoomStepnumber0.5
slideshowboolean | { delay, playOnOpen }false
thumbnailsbooleantrue
counterbooleantrue
captionbooleantrue
arrowsbooleantrue
toolbarboolean | LightboxToolbarItem[]true
transition'fade' | 'scale' | 'none' | boolean'fade'
iconsLightboxIcons-
trapFocusbooleantrue
preventScrollbooleantrue
classstring-
uiRecord<Slot, Class>-