Skip to content
ShelamkoffPublic

About

Framework-agnostic fullscreen lightbox and media gallery with animations, zoom, thumbnails, video, iframes, and composable plugins.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

@shelamkoff/expose

Quality npm version Live demo MIT license

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 · Русская версия

Highlights

  • 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.

Installation

npm install @shelamkoff/expose

Import 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.

Quick start

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.

Slide sources

interface SlideData {
  src: string | ImageSource | VideoSource | IFrameSource | RenderFunction
  caption?: string
  thumb?: string
  alt?: string
  download?: string | boolean
  preview?: string | (() => HTMLElement)
}

Image

const imageSlide = {
  src: {
    type: 'image',
    url: '/photo.jpg',
    srcset: '/photo-640.jpg 640w, /photo-1280.jpg 1280w',
    sizes: '100vw',
  },
  alt: 'Accessible description',
}

Video

const videoSlide = {
  src: {
    type: 'video',
    url: '/clip.mp4',
    autoplay: false,
    muted: false,
    loop: false,
    poster: '/poster.jpg',
  },
}

Iframe

const iframeSlide = {
  src: {
    type: 'iframe',
    url: 'https://example.com/embed',
    allow: 'fullscreen',
    sandbox: 'allow-scripts allow-same-origin',
  },
}

Custom renderer

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.

Configuration

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.

Public API

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.

Events

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

Toolbar buttons

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.

Animations

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.

Built-in plugins

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.

Creating a plugin

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.

Security boundary

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.

Demo

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 demo

Open http://127.0.0.1:4173/.

Verification

npm install
npm test
npm run typecheck
npm run build
npx playwright install chromium
npm run test:browser

npm run check runs all four checks after Chromium is installed. GitHub Actions runs the same verification on master.

Package exports

  • @shelamkoff/expose — JavaScript API and TypeScript declarations.
  • @shelamkoff/expose/styles.css — required gallery styles.
  • @shelamkoff/expose/package.json — package metadata.

License

MIT.

About

Framework-agnostic fullscreen lightbox and media gallery with animations, zoom, thumbnails, video, iframes, and composable plugins.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages