Framework-agnostic fullscreen lightbox and media gallery for images, video, iframes, and application-rendered content. It provides asynchronous navigation, named animations, an owned toolbar, typed events, focus and scroll management, and composable plugins as an ESM package for modern browsers.
Live demo · npm · Русская версия
- Image, video, iframe, and application-rendered slides.
- Named transitions plus zoom, thumbnails, captions, autoplay, transform, download, and fullscreen plugins.
- Keyboard, swipe, focus restoration, and reference-counted scroll locking.
- Framework-independent JavaScript API with TypeScript declarations.
npm install @shelamkoff/exposeImport the required stylesheet once:
import '@shelamkoff/expose/styles.css'The package installs @shelamkoff/event-bus automatically as a dependency.
The static GitHub Pages demo includes a pinned copy of the MIT-licensed EventBus module at assets/event-bus.js, so its JavaScript runs without a third-party CDN. The npm package itself continues using the dependency normally.
exposeStylesUrl is exported for hosts that create their own <link> element.
import {
Expose,
createCaptions,
createFullscreen,
createThumbnails,
createZoom,
} from '@shelamkoff/expose'
import '@shelamkoff/expose/styles.css'
const gallery = new Expose([
{
src: '/photos/forest.jpg',
thumb: '/photos/forest-thumb.jpg',
alt: 'Forest',
caption: 'Winter forest',
},
{
src: {
type: 'video',
url: '/video/trailer.mp4',
poster: '/video/poster.jpg',
},
},
], {
toolbar: ['counter'],
counterFormat: '{current} / {total}',
plugins: [
createCaptions(),
createThumbnails(),
createZoom(),
createFullscreen(),
],
})
const unsubscribe = gallery.on('slide:change', ({ index, slide }) => {
console.log(index, slide.caption)
})
await gallery.open(0)
// Later:
unsubscribe()
await gallery.close()
gallery.destroy()open(), close(), next(), prev(), and goTo() are asynchronous because they wait for the active animation. Await them when application state depends on completion.
interface SlideData {
src: string | ImageSource | VideoSource | IFrameSource | RenderFunction
caption?: string
thumb?: string
alt?: string
download?: string | boolean
preview?: string | (() => HTMLElement)
}const imageSlide = {
src: {
type: 'image',
url: '/photo.jpg',
srcset: '/photo-640.jpg 640w, /photo-1280.jpg 1280w',
sizes: '100vw',
},
alt: 'Accessible description',
}const videoSlide = {
src: {
type: 'video',
url: '/clip.mp4',
autoplay: false,
muted: false,
loop: false,
poster: '/poster.jpg',
},
}const iframeSlide = {
src: {
type: 'iframe',
url: 'https://example.com/embed',
allow: 'fullscreen',
sandbox: 'allow-scripts allow-same-origin',
},
}const customSlide = {
src: () => {
const element = document.createElement('article')
element.textContent = 'Application-owned content'
return {
element,
destroy() {
// Release renderer-owned resources here.
},
}
},
}A plain string is classified from its URL extension; use an explicit source object when the URL is ambiguous. The optional preview property is application metadata used in the online demo; the library uses thumb for the thumbnails plugin. A custom renderer may return an HTMLElement directly or { element, destroy }. It must do so synchronously; Promise-returning renderers produce a visible render error while their rejected Promises are observed.
| Option | Type | Default | Purpose |
|---|---|---|---|
loop |
boolean |
true |
Wraps previous/next navigation at the ends. |
navigation |
boolean |
true |
Displays previous and next arrow buttons. Keyboard and swipe navigation remain separate. |
closeOnBackdrop |
boolean |
true |
Closes when the backdrop is clicked. |
animation |
string |
'fade' |
Registered animation name. |
animationDuration |
number |
300 |
Non-negative duration in milliseconds. |
preload |
number |
1 |
Number of neighboring slides retained on each side; slides outside this window are disposed. |
startIndex |
number |
0 |
Initial index used when open() receives no index. |
toolbar |
ToolbarItem[] |
[] |
'counter' and custom button definitions. |
counterFormat |
string |
'{current} / {total}' |
Counter template. |
plugins |
ExposePlugin[] |
[] |
Plugins installed during construction. |
Invalid option and slide shapes throw synchronously during construction or mutation. The counter rolls upward for every change, including the loop from the last slide to the first.
| Method | Result | Description |
|---|---|---|
Expose.registerAnimation(name, animation) |
void |
Registers a global named animation. |
use(plugin) |
this |
Installs a plugin while the gallery is closed. |
getPlugin(name) |
plugin or undefined |
Returns an installed public plugin object. |
open(index?) |
Promise<void> |
Builds and opens the overlay. No-op for an empty gallery. |
close() |
Promise<void> |
Closes the overlay. Repeated calls share the in-flight close operation. |
next() / prev() |
Promise<void> |
Navigates one slide when open and not already animating. |
goTo(index) |
Promise<void> |
Navigates to a valid slide index. |
getIndex() |
number |
Returns the current index, or -1 when no slide is selected. |
getSlide() |
slide or null |
Returns the current slide. |
getSlides() |
SlideData[] |
Returns a defensive array copy. |
setSlides(slides) |
void |
Replaces all slides. An empty array closes an open gallery. |
addSlide(slide) |
void |
Appends a slide. |
removeSlide(index) |
void |
Removes a slide unless a transition is active. Removing the last slide closes the gallery. |
isOpen() |
boolean |
Reports overlay state. |
on / off / once |
subscription API | Manages event handlers; on and once return unsubscribe functions. |
destroy() |
void |
Permanently releases animations, plugins, overlay DOM, scroll lock, and listeners. |
Slide mutations are supported while open, except during closing and the guarded removal case above. Do not use the instance after destroy().
Multiple galleries share a reference-counted body scroll lock. Only the topmost open gallery handles global keyboard input, and focus is restored to the original host element after the final gallery closes.
Rendered slides are windowed to at most 2 * preload + 1 entries (or the total slide count). Revisited slides may be rendered again; custom renderers should release resources in destroy(). Preloaded videos do not autoplay until active, and inactive iframes load about:blank until selected. The active animation automatically becomes none when the user prefers reduced motion.
| Event | Payload |
|---|---|
open / open:complete |
{ index } |
close / close:complete |
no payload |
slide:change |
{ index, slide } |
slide:load |
{ index, element } |
slides:change |
{ slides } |
zoom:change |
{ scale } |
fullscreen:change |
{ active } |
rotate |
{ rotation } |
flip |
{ flipH, flipV } |
autoplay:start / autoplay:stop |
no payload |
destroy |
no payload |
const gallery = new Expose(slides, {
toolbar: [
'counter',
{
name: 'copy-link',
title: 'Copy link',
icon: '<svg viewBox="0 0 24 24" aria-hidden="true">...</svg>',
visible: slide => typeof slide.src === 'string',
async onClick() {
await navigator.clipboard.writeText(location.href)
},
},
],
})A button may also define className, toggle, active, and onStateChange(active). Button names must be unique. Icon strings are inserted as trusted HTML.
An animation implements enter, exit, and transition. Each method may return a promise and receives an optional AbortSignal as its final argument:
Expose.registerAnimation('instant', {
enter(overlay) {
overlay.style.opacity = '1'
},
exit(overlay) {
overlay.style.opacity = '0'
},
transition(current, next) {
current.hidden = true
next.hidden = false
},
})Stop animation-owned timers and frames when the signal aborts. A failing custom animation is caught and the gallery restores a stable visual state instead of becoming locked.
- Captions — slide captions.
- Zoom — image zoom and pan.
- Thumbnails — thumbnail navigation.
- Autoplay — interval playback.
- Transform — rotation and horizontal/vertical flip.
- Download — slide download/open action.
- Fullscreen — Fullscreen API integration.
Plugin installation and teardown hooks are synchronous. Returning a Promise from install() is rejected; rejected teardown Promises are observed and logged but cannot be awaited by destroy(). Retained plugin contexts become inactive after installation rollback or gallery destruction; using one to register events, toolbar buttons or gestures throws. Plugins may only be installed while the gallery is closed. A stateful plugin instance may belong to one live gallery at a time.
export function createSharePlugin() {
let context = null
return {
name: 'share',
install(pluginContext) {
context = pluginContext
pluginContext.toolbar.add({
name: 'share',
title: 'Share',
icon: '<svg viewBox="0 0 24 24" aria-hidden="true">...</svg>',
visible: slide => pluginContext.resolveType(slide.src) === 'image',
async onClick() {
const slide = pluginContext.getSlide()
const source = typeof slide?.src === 'object' ? slide.src.url : slide?.src
if (typeof source === 'string' && navigator.share) {
try {
await navigator.share({ url: source })
} catch {
// User cancellation is expected.
}
}
},
})
},
destroy() {
context = null
},
}
}The frozen plugin context exposes owned event subscriptions, asynchronous navigation, read-only state and options, live overlay/slide DOM getters, owned toolbar registration, gestures.setSwipeBlocked(boolean) for suppressing navigation during plugin-owned pan/pinch gestures, and resolveType(source). Context subscriptions and toolbar registrations are rolled back after failed installation and removed on destruction. The plugin still owns its global listeners, observers, timers, frames, object URLs, third-party objects, and any DOM it creates outside the managed overlay lifecycle.
Media URLs are validated before DOM assignment. Active schemes are rejected. Iframe slides accept relative, HTTP, and HTTPS URLs, but not data: or blob: documents. The host remains responsible for a custom iframe sandbox policy. Explicit sandbox: '' applies the strictest sandbox; omitting the property leaves the iframe unsandboxed. Keyboard events from a focused cross-origin iframe cannot be intercepted by the host page; users can always close the gallery with the toolbar button. Custom render functions and toolbar icon HTML are trusted developer code; validate or sanitize any application data used there.
Open the live demo to try mixed slide types, animations, toolbar actions, and built-in plugins.
The demo server refuses access to hidden configuration and Git metadata. To run the same demo locally:
npm install
npm run demoOpen http://127.0.0.1:4173/.
npm install
npm test
npm run typecheck
npm run build
npx playwright install chromium
npm run test:browsernpm run check runs all four checks after Chromium is installed. GitHub Actions runs the same verification on master.
@shelamkoff/expose— JavaScript API and TypeScript declarations.@shelamkoff/expose/styles.css— required gallery styles.@shelamkoff/expose/package.json— package metadata.
MIT.