Vue Lightbox
Full-screen image & video gallery lightbox for Vue 3, built on @reelkit/vue-lightbox.
Features
Installation
npm install @reelkit/vue-lightbox @reelkit/vue lucide-vue-nextDon't forget to import the styles:
import '@reelkit/vue-lightbox/styles.css';Icons
The default controls use lucide-vue-next for icons. If you prefer a different icon library, use the #controls and #navigation scoped slots to provide your own.
Quick Start
Import the stylesheet and the LightboxOverlay component, then drive open/close with v-model:is-open.
<script setup lang="ts">
import { ref } from 'vue';
import { LightboxOverlay, type LightboxItem } from '@reelkit/vue-lightbox';
import '@reelkit/vue-lightbox/styles.css';
const images: LightboxItem[] = [
{
src: '/cdn/samples/images/image-01.jpg',
title: 'Mountain River',
description: 'A beautiful mountain river',
},
{
src: '/cdn/samples/images/image-02.jpg',
title: 'Snowy Peaks',
},
{
src: '/cdn/samples/images/image-03.jpg',
title: 'Misty Forest',
description: 'Morning fog over the forest canopy',
},
{
src: '/cdn/samples/images/image-04.jpg',
title: 'Autumn Trail',
},
{
src: '/cdn/samples/images/image-05.jpg',
title: 'Ocean Cliff',
description: 'Dramatic cliffs above the Pacific',
},
{
src: '/cdn/samples/images/image-06.jpg',
title: 'Desert Dunes',
},
];
const open = ref(false);
const startIndex = ref(0);
function openAt(i: number) {
startIndex.value = i;
open.value = true;
}
</script>
<template>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px">
<button
v-for="(img, i) in images"
:key="img.src"
style="aspect-ratio:4/3;cursor:pointer"
@click="openAt(i)"
>
<img :src="img.src" style="width:100%;height:100%;object-fit:cover" />
</button>
</div>
<LightboxOverlay
v-model:is-open="open"
:items="images"
:initial-index="startIndex"
/>
</template>Scoped Slots
Six named scoped slots allow full customisation of the overlay surfaces. Omit the slot to keep the built-in default; provide nothing inside the slot (e.g. via v-if="false") to hide that section entirely.
| Slot | Scope | Description |
|---|---|---|
| #slide | SlideSlotScope | Replace individual slide content (required for video slides) |
| #controls | ControlsSlotScope | Replace the top controls bar (close, counter, fullscreen) |
| #navigation | NavigationSlotScope | Replace the prev/next navigation arrows |
| #info | InfoSlotScope | Replace the bottom title/description gradient overlay |
| #loading | LoadingSlotScope | Custom loading indicator |
| #error | ErrorSlotScope | Custom error indicator |
<template>
<LightboxOverlay v-model:is-open="open" :items="items">
<!-- Custom info overlay -->
<template #info="{ item }">
<div class="my-caption">
<h2>{{ item.title }}</h2>
<p>{{ item.description }}</p>
</div>
</template>
<!-- Custom navigation -->
<template #navigation="{ onPrev, onNext, activeIndex, count }">
<div class="my-nav">
<button :disabled="activeIndex === 0" @click="onPrev">Prev</button>
<span>{{ activeIndex + 1 }} / {{ count }}</span>
<button :disabled="activeIndex === count - 1" @click="onNext">Next</button>
</div>
</template>
<!-- Custom controls -->
<template #controls="{ onClose, isFullscreen, onToggleFullscreen }">
<div class="my-controls">
<button @click="onToggleFullscreen">
{{ isFullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen' }}
</button>
<button @click="onClose">Close</button>
</div>
</template>
</LightboxOverlay>
</template>Video Support
Video slides are opt-in so the default bundle stays free of audio/video wiring. Call useVideoSlideRenderer(items) and forward the returned VideoSlideRenderer / VideoControlsRenderer into the overlay's #slide and #controls slots. Wrap the overlay in the returned SoundProvider so the built-in sound toggle has a context.
<script setup lang="ts">
import { ref } from 'vue';
import {
LightboxOverlay,
useVideoSlideRenderer,
type LightboxItem,
} from '@reelkit/vue-lightbox';
import '@reelkit/vue-lightbox/styles.css';
const open = ref(false);
const items: LightboxItem[] = [
{ src: '/image-01.jpg', title: 'Image' },
{
type: 'video',
src: '/clip.mp4',
poster: '/clip.jpg',
title: 'Clip',
},
];
const { VideoSlideRenderer, VideoControlsRenderer, SoundProvider } =
useVideoSlideRenderer(items);
</script>
<template>
<SoundProvider>
<LightboxOverlay v-model:is-open="open" :items="items">
<template #slide="scope">
<VideoSlideRenderer v-bind="scope" />
</template>
<template #controls="scope">
<VideoControlsRenderer v-bind="scope" />
</template>
</LightboxOverlay>
</SoundProvider>
</template>The shared <video> element powering video slides uses the same pattern as the vue reel-player — playback continues across slide changes on iOS without requiring a per-slide user gesture.
Fullscreen
Use useFullscreen from @reelkit/vue to observe or toggle fullscreen state on a referenced element. The lightbox drives its built-in fullscreen button through the same composable.
<script setup lang="ts">
import { shallowRef } from 'vue';
import { useFullscreen } from '@reelkit/vue';
const containerRef = shallowRef<HTMLDivElement | null>(null);
const { isFullscreen, toggle } = useFullscreen({ elementRef: containerRef });
</script>
<template>
<div ref="containerRef">
<button @click="toggle">
{{ isFullscreen.value ? 'Exit fullscreen' : 'Enter fullscreen' }}
</button>
</div>
</template>URL State
View live demo →Build a controller with useOverlayUrlState from @reelkit/vue and hand it to LightboxUrlOverlay as controller, and the address bar owns the gallery: it opens itself when the parameter names a slide and closes when the parameter goes away. Links are shareable, and the back button closes the gallery. It is a separate component from LightboxOverlay, so each carries exactly one open-state driver — the is-open model or the url controller, never both.
Built-in keys
You can address slides with a built-in key — spread urlIndexKey (by position) or urlStableIdKey (by a stable id) into the controller — both re-exported from @reelkit/vue. See the URL State guide and Core API.
Back closes only when you opened from within the app — the link pushed an entry, so back pops to the gallery. A shared link opened directly in a fresh tab has no history behind it, so browser-back leaves the site; the close button or Escape removes the parameter in place and keeps you on the gallery.
<script setup lang="ts">
import { LightboxUrlOverlay, type LightboxItem } from '@reelkit/vue-lightbox';
import { useOverlayUrlState, urlIndexKey, urlStableIdKey } from '@reelkit/vue';
import '@reelkit/vue-lightbox/styles.css';
const props = defineProps<{ images: LightboxItem[] }>();
const photo = useOverlayUrlState({
param: 'photo',
...urlIndexKey(() => props.images.length),
});
</script>
<template>
<!-- Opening is a link — the href is the open action. No open flag, no
handler: the overlay reads the URL and opens itself. -->
<RouterLink v-for="(img, i) in props.images" :key="img.src" :to="`?photo=${i}`">
<img :src="img.src" />
</RouterLink>
<LightboxUrlOverlay :controller="photo" :items="props.images" />
</template>The composable takes one options object and returns a UrlStateController (with set, index, value). Keep it for programmatic control: set is the low-level write the overlay uses internally (slide change, and set(null) to close). It drives the overlay programmatically too — set(index) opens it, the same as navigating to the parameter. Prefer a link for opening though: the href is shareable, opens in a new tab, and the back button closes it — all for free, with no handler.
Full useOverlayUrlState options (param, adapter, codec, locator): see the Vue API reference.
LightboxUrlOverlay itself takes just :controller (required), a @close emit, plus every visual and behavior prop LightboxOverlay forwards (items, transition-fn, the scoped slots, and so on) — but no is-open.
- Opening costs one history entry; paging slides replaces it, so a hundred swipes add none — one back step always leaves the gallery.
- A shared link like
?photo=3opens the gallery at that slide. A parameter naming no slide is dropped from the URL instead of asserting a slide that cannot open.
In a routed app, pass an adapter. Writing history directly leaves the router's own location stale, and its next navigation drops the parameter.
<script setup lang="ts">
import { useVueRouterUrlAdapter } from '@reelkit/vue/vue-router-url-adapter';
const adapter = useVueRouterUrlAdapter();
const photo = useOverlayUrlState({
param: 'photo',
adapter,
...urlIndexKey(() => images.length),
});
</script>
<template>
<LightboxUrlOverlay :controller="photo" :items="images" />
</template>Stable links. The index is positional, so a bookmark opens a different image once the list is reordered. urlStableIdKey keys by each item's stable id, scanning the live list — one call covers the common case.
<script setup lang="ts">
const photo = useOverlayUrlState({
param: 'photo',
...urlStableIdKey({ items: () => images }),
});
</script>
<template>
<LightboxUrlOverlay :controller="photo" :items="images" />
</template>Pass hashCodec: base64UrlCodec to base64url-encode the id in the URL — reversible obfuscation, not a cryptographic hash.
Key by a different field (a slug), or page an infinite feed with locateAsync, and build the codec/locator yourself: codec spells the identity into the URL, locator finds where it now sits.
<script setup lang="ts">
const photo = useOverlayUrlState({
param: 'photo',
codec: { decode: (raw) => raw, encode: (id) => id },
locator: {
locate: (id) => images.findIndex((x) => x.slug === id),
identify: (index) => images[index].slug,
},
});
</script>
<template>
<LightboxUrlOverlay :controller="photo" :items="images" />
</template>Infinite / paginated galleries. locate is synchronous, so it can only answer for items already loaded — a shared link to image 400 of a feed that has loaded 20 comes up empty. locateAsync is the fallback, called only when it misses: load the pages you need, then return the index the identity turned out to have.
Shortcut
Keying by the item’s id? Skip the hand-rolled codec and locator — pass locateAsync straight to urlStableIdKey({ items, locateAsync }) (it fetches on a miss, then returns the index). The fuller version below is for keying by another field, or for full control.
<script setup lang="ts">
const photo = useOverlayUrlState({
param: 'photo',
codec: { decode: (raw) => raw, encode: (id) => id },
locator: {
locate: (id) => items.value.findIndex((x) => x.id === id),
identify: (index) => items.value[index].id,
locateAsync: async (id) => {
const loaded = await loadById(id); // or loadUntil(id) — fetch just that one, or page up to it
if (!loaded) return null; // exhausted — link names no item
items.value = loaded; // commit; the overlay renders from this
return loaded.findIndex((x) => x.id === id); // wherever it landed
},
},
});
</script>
<template>
<LightboxUrlOverlay :controller="photo" :items="items" />
</template>While it is pending the lightbox stays closed and the parameter is left alone, so the deep link survives the fetch. null or a rejection drops the parameter. An answer arriving after the URL moved on, after a close, or after unmount is discarded, so a slow fetch cannot open a slide nobody asked for. Whatever it returns is authoritative — it reports the index of data it just fetched, and the lightbox takes it as-is rather than re-reading items, which Vue has not re-rendered yet.
API Reference
LightboxOverlay Props
LightboxOverlayProps
| Prop | Type | Default | Description |
|---|---|---|---|
isOpen | boolean | required | Controls visibility; when false the overlay is removed from the DOM. Bindable via v-model:is-open. |
items | LightboxItem[] | required | Array of items (images or videos) |
initialIndex | number | 0 | Zero-based index of the initially visible item |
transitionFn | TransitionTransformFn | slideTransition | Slide transition function. Import a built-in (slideTransition, flipTransition, lightboxFadeTransition, lightboxZoomTransition) or pass a custom one. Defaults to slideTransition when omitted. |
showInfo | boolean | true | Whether to render the title/description info overlay |
showControls | boolean | true | Whether to render the top controls bar (close, counter, fullscreen) |
showNavigation | boolean | true | Whether to render the prev/next navigation arrows (desktop only) |
transitionDuration | number | 300 | Slide animation duration in ms |
swipeDistanceFactor | number | 0.12 | Minimum swipe distance fraction (0–1) to trigger slide change |
swipeToCloseDirection | 'up' | 'down' | 'up' | Direction of the swipe-to-close gesture on mobile |
loop | boolean | false | Whether the slider wraps from the last slide back to the first |
enableNavKeys | boolean | true | Enable keyboard arrow-key navigation |
enableWheel | boolean | true | Enable mouse-wheel navigation |
wheelDebounceMs | number | 200 | Debounce duration for wheel events in ms |
ariaLabel | string | 'Image gallery' | Accessible label for the dialog region |
LightboxUrlOverlay Props
LightboxUrlOverlayProps
Takes every visual and behaviour prop above except is-open, and replaces it with a controller. It emits close, slide-change and api-ready, but no update:is-open. initial-index is ignored here — the controller's position picks the slide, so a value passed alongside it would be overwritten on every open.
| Prop | Type | Default | Description |
|---|---|---|---|
controller | UrlStateController | required | Controller from useOverlayUrlState. Its position decides whether the overlay is open and which slide it shows; the overlay writes back through it on slide change and on close. |
LightboxOverlay Events
| Event | Payload | Description |
|---|---|---|
close | void | Emitted when the user closes the lightbox |
slide-change | number | Emitted with the new active slide index after a change |
api-ready | LightboxApi | Emitted once the slider is ready, exposing the imperative API |
update:is-open | boolean | Emitted on close; enables v-model:is-open |
LightboxApi
The object api-ready emits, also exposed on the component's template ref. The slider exists only while the overlay is open, so every slider method is a no-op while it is closed; close() always works.
| Method | Type | Description |
|---|---|---|
next() | () => void | Moves to the next slide, animated. Stays on the last slide unless loop is on. |
prev() | () => void | Moves to the previous slide, animated. Stays on the first slide unless loop is on. |
goTo(index, animate?) | (number, boolean?) => Promise<void> | Jumps to a slide. index is clamped to the items, animate defaults to false. Resolves when the move has finished. |
adjust() | () => void | Recomputes the slide position after a layout change the overlay did not see itself |
observe() | () => void | Starts listening for gesture, keyboard and wheel input again after unobserve() |
unobserve() | () => void | Stops listening for gesture, keyboard and wheel input; the slides stay where they are |
close() | () => void | Closes the lightbox, the same as the close button or Escape |
LightboxItem Interface
| Field | Type | Required | Description |
|---|---|---|---|
src | string | yes | URL of the image or video |
type | 'image' | 'video' | no | Item type. Defaults to 'image' |
poster | string | no | Thumbnail image for video items |
title | string | no | Title shown in the info overlay |
description | string | no | Description shown below the title |
width | number | no | Intrinsic image width in pixels |
height | number | no | Intrinsic image height in pixels |
Slot Scope Types
| Type | Fields |
|---|---|
SlideSlotScope | { item, index, size: [number, number], isActive, onReady, onWaiting, onError } |
ControlsSlotScope | { item, activeIndex, count, isFullscreen, onClose, onToggleFullscreen } |
NavigationSlotScope | { item, activeIndex, count, onPrev, onNext } |
InfoSlotScope | { item, index } |
LoadingSlotScope | { item, activeIndex } |
ErrorSlotScope | { item, activeIndex } |
Transitions
Pass any TransitionTransformFn via the transition-fn prop. Importing only the transition you use lets the bundler tree-shake the rest. Defaults to slideTransition when omitted.
| Function | Description |
|---|---|
slideTransition | Default. Horizontal translate between slides; re-exported from @reelkit/vue. |
lightboxFadeTransition | Crossfade with a subtle horizontal nudge. Local to @reelkit/vue-lightbox. |
flipTransition | 3D flip around the Y-axis; re-exported from @reelkit/vue. |
lightboxZoomTransition | Incoming slide scales 70% → 100% with fade. Local to @reelkit/vue-lightbox. |
<script setup lang="ts">
import {
LightboxOverlay,
lightboxFadeTransition,
lightboxZoomTransition,
} from '@reelkit/vue-lightbox';
</script>
<template>
<!-- Built-in transition -->
<LightboxOverlay
v-model:is-open="open"
:items="items"
:transition-fn="lightboxFadeTransition"
/>
<!-- Different built-in -->
<LightboxOverlay
v-model:is-open="open"
:items="items"
:transition-fn="lightboxZoomTransition"
/>
</template>Content Loading & Error Handling
When you take over rendering via the #slide slot, three lifecycle callbacks are available on the slot scope to report loading state. The lightbox tracks per-slide state and shows a spinner or error icon accordingly. A content preloader caches broken URLs so revisiting a failed slide skips the retry.
Lifecycle callbacks
| Callback | Type | Description |
|---|---|---|
onReady | () => void | Notify that the slide content has loaded successfully (e.g. image decoded) |
onWaiting | () => void | Notify that the slide content is loading/buffering (shows spinner) |
onError | () => void | Notify that the slide content failed to load (shows error icon) |
Wiring callbacks in #slide
<template>
<LightboxOverlay v-model:is-open="open" :items="items">
<template
#slide="{ item, size, isActive, onReady, onWaiting, onError }"
>
<template v-if="item.type === 'video'">
<video
:src="item.src"
:poster="item.poster"
:autoplay="isActive"
:style="{ width: `${size[0]}px`, height: `${size[1]}px`, objectFit: 'contain' }"
@canplay="onReady"
@waiting="onWaiting"
@error="onError"
/>
</template>
<template v-else>
<img
:src="item.src"
:style="{ width: `${size[0]}px`, height: `${size[1]}px`, objectFit: 'contain' }"
@load="onReady"
@error="onError"
/>
</template>
</template>
</LightboxOverlay>
</template>Custom loading slot
Use the #loading slot to replace the default spinner.
<template>
<LightboxOverlay v-model:is-open="open" :items="items">
<template #loading="{ item, activeIndex }">
<div class="my-loading">
<span>Loading image {{ activeIndex + 1 }}…</span>
<span class="muted">{{ item.title }}</span>
</div>
</template>
</LightboxOverlay>
</template>Custom error slot
Use the #error slot to replace the default broken-image icon.
<template>
<LightboxOverlay v-model:is-open="open" :items="items">
<template #error="{ item, activeIndex }">
<div class="my-error">
<span>Failed to load</span>
<span class="muted">{{ item.title ?? item.src }}</span>
</div>
</template>
</LightboxOverlay>
</template>CSS Classes
All CSS classes are plain (not scoped), so they can be targeted with higher-specificity selectors in a stylesheet loaded after @reelkit/vue-lightbox/styles.css. For color, size, and z-index changes, prefer the CSS custom properties documented in the Theming section below.
| Class | Component | Description |
|---|---|---|
.rk-lightbox-overlay | Overlay | Root container (full-screen backdrop) |
.rk-lightbox-top-shade | Overlay | Top gradient scrim behind controls |
.rk-lightbox-spinner | Overlay | Default loading spinner |
.rk-lightbox-error | Overlay | Error state container (broken image) |
.rk-lightbox-error-text | Overlay | Error state text label |
.rk-lightbox-controls-left | Controls | Top-left controls container |
.rk-lightbox-btn | Controls | Control button (fullscreen, sound, etc.) |
.rk-lightbox-close | Controls | Close button |
.rk-lightbox-counter | Controls | Image counter chip |
.rk-lightbox-nav | Navigation | Navigation arrow (both prev and next) |
.rk-lightbox-nav-prev | Navigation | Previous arrow |
.rk-lightbox-nav-next | Navigation | Next arrow |
.rk-lightbox-info | Info | Title / description container |
.rk-lightbox-info-title | Info | Image title |
.rk-lightbox-info-description | Info | Image description |
.rk-lightbox-slide | Slide | Slide container |
.rk-lightbox-img | Slide | Image element |
.rk-lightbox-video-container | VideoSlide | Video slide container (opt-in) |
.rk-lightbox-video-element | VideoSlide | Video element (opt-in) |
.rk-lightbox-video-poster | VideoSlide | Video poster image (opt-in) |
Theming
Override any --rk-lightbox-* CSS custom property on :root (or any ancestor of .rk-lightbox-overlay) to retheme. Direct declarations on .rk-lightbox-overlay would shadow inherited values, so keep overrides on an ancestor selector.
| Token | Default | Controls |
|---|---|---|
--rk-lightbox-overlay-bg | #000 | Backdrop color |
--rk-lightbox-overlay-z | 9999 | Overlay z-index |
--rk-lightbox-top-shade-height | 80px | Top scrim height |
--rk-lightbox-top-shade-bg | linear-gradient(rgba(0,0,0,0.6), transparent) | Top scrim gradient |
--rk-lightbox-edge-padding | 16px | Edge inset for close / nav / controls |
--rk-lightbox-btn-bg | rgba(0, 0, 0, 0.5) | Default background for close / nav / small buttons |
--rk-lightbox-btn-bg-hover | rgba(255, 255, 255, 0.2) | Hover background for close / nav / small buttons |
--rk-lightbox-btn-fg | #fff | Icon color for close / nav / small buttons |
--rk-lightbox-btn-size | 36px | Small button size (fullscreen toggle, etc.) |
--rk-lightbox-close-size | 40px | Close button size |
--rk-lightbox-nav-size | 48px | Prev / next arrow size |
--rk-lightbox-nav-opacity | 0.7 | Idle opacity of prev / next arrows |
--rk-lightbox-counter-bg | rgba(0, 0, 0, 0.5) | Counter chip background |
--rk-lightbox-counter-fg | #fff | Counter text color |
--rk-lightbox-info-bg | linear-gradient(transparent, rgba(0,0,0,0.8)) | Caption scrim gradient |
--rk-lightbox-title-size | 18px | Title font size |
--rk-lightbox-description-size | 14px | Description font size |
--rk-lightbox-video-bg | #000 | Letterbox background behind <video> |
<style>
:root {
--rk-lightbox-overlay-bg: #0f172a;
--rk-lightbox-btn-bg: rgba(99, 102, 241, 0.65);
--rk-lightbox-btn-bg-hover: rgba(168, 85, 247, 0.85);
--rk-lightbox-info-bg: linear-gradient(
transparent,
rgba(99, 102, 241, 0.55) 60%,
rgba(168, 85, 247, 0.85)
);
}
</style>Accessibility
The overlay root is a modal dialog (role="dialog", aria-modal="true"). Set the aria-label prop to change the screen-reader announcement; it defaults to "Image gallery". Each slide carries role="group", aria-roledescription="slide", and an aria-label derived from the position (e.g. "Image 2 of 5").
The lightbox captures focus on open and returns it to the trigger on close. Tab and Shift+Tab cycle through focusable elements inside; focus that escapes (click outside, programmatic focus) gets pulled back. Implemented with captureFocusForReturn and createFocusTrap from @reelkit/vue.
Keyboard Shortcuts
| Key | Action |
|---|---|
ArrowLeft | Previous image |
ArrowRight | Next image |
Escape | Close lightbox (or exit fullscreen if active) |