Accessible Gallery Lightbox for Images, Video, and Iframes – pencere

Accessible Gallery Lightbox for Images, Video, and Iframes – pencere
Accessible Gallery Lightbox for Images, Video, and Iframes – pencere
pencere is a JavaScript/TypeScript media viewer library that displays your images, videos, iframes, and custom content in a responsive, accessible gallery lightbox.

It uses the View Transitions API for smooth morphing effects and works as a framework-agnostic solution with optional adapters for React, Vue, Svelte, Solid, and Web Components.

Features:

  • Supports image, video, iframe, and custom HTML content types in a single lightbox.
  • Native thumbnail-to-lightbox morph via the View Transitions API.
  • Full WCAG 2.2 AA compliance including focus management, 44×44 target sizes, and dragging alternatives for keyboard users.
  • Hash-based deep linking writes URL fragments on slide change and reads them on page load.
  • Pinch-to-zoom, double-tap toggle, mouse wheel zoom, and drag-to-pan on zoomed images.
  • Swipe-down gesture dismisses the lightbox with a backdrop fade.
  • RTL auto-detection flips arrow keys, swipe direction, and layout for Arabic and Hebrew content.
  • Fullscreen API with an iOS CSS fallback for browsers that restrict fullscreen to video elements.
  • Declarative HTML scanner that groups links by gallery name and lazy-constructs a lightbox on first click.
  • Typed event emitter with lifecycle hooks for open, render, slide change, load, and close.
  • All visual properties exposed as CSS custom properties.
  • Strict CSP compatibility via adoptedStyleSheets and a Trusted Types policy helper for HTML captions.
  • Built-in translations for 14 languages including Arabic, Japanese, Simplified Chinese, and Korean.
  • IME-safe keyboard shortcuts that ignore input composition events.
  • SLSA-attested npm releases with SRI hashes for CDN use.
  • Opt-in haptic feedback on coarse-pointer devices via the Vibration API.
  • Responsive image support with srcset, sizes, and sources for AVIF and WebP selection.
  • ThumbHash and BlurHash placeholder cross-fades before the full image loads.

Use Cases:

  • Portfolio sites can open full-resolution images in a morphing overlay from thumbnail grids.
  • E-commerce product pages can display images, video, and 3D models in one lightbox.
  • News and editorial sites can deep-link directly to a specific gallery slide from external URLs.

How to use it:

1. Install the library via npm and import it into your JS project:

# NPM
$ npm install pencere
import { PencereViewer } from "pencere"

2. Or load it from a CDN.

<script
  type="module"
  src="https://cdn.jsdelivr.net/npm/pencere/dist/index.mjs"
  crossorigin="anonymous"
></script>

3. Create a gallery lightbox with a list of image items:

const viewer = new PencereViewer({
  items: [
    { type: "image", src: "/photos/coast.jpg", alt: "Coastal cliffs", caption: "Pacific Coast" },
    { type: "image", src: "/photos/forest.jpg", alt: "Redwood forest" },
  ],
  loop: true,
  viewTransition: true, // enables thumbnail-to-lightbox morph via View Transitions API
  routing: true,        // writes #p1, #p2 fragments on every slide change
})
// Pass the clicked element so the browser can animate the morph natively
document
  .querySelector<HTMLButtonElement>("#gallery-trigger")
  ?.addEventListener("click", (e) => viewer.open(0, e.currentTarget))
// Pass the clicked element so the browser can animate the morph natively
document
  .querySelector<HTMLButtonElement>("#gallery-trigger")
  ?.addEventListener("click", (e) => viewer.open(0, e.currentTarget))

4. For plain HTML pages, bindPencere scans the DOM, groups items by data-gallery, and lazy-constructs a viewer on the first click. Modifier clicks (Cmd/Ctrl+click) still open in a new tab.

<a href="/photos/coast.jpg" data-pencere data-gallery="nature" data-caption="Pacific Coast">
  <img src="/photos/coast-thumb.jpg" alt="Coastal cliffs" />
</a>
<a href="/photos/forest.jpg" data-pencere data-gallery="nature" data-caption="Redwood Forest">
  <img src="/photos/forest-thumb.jpg" alt="Redwood forest" />
</a>
<script type="module">
  import { bindPencere } from "pencere"
  // Scans matching elements, groups by data-gallery, and opens the viewer on click
  const unbind = bindPencere("[data-pencere]")
  // Call unbind() to remove the delegated click handler
</script>

5. The type field on each item selects its renderer. Built-in types are "video", "iframe", and "html". Register custom renderers for any other content type:

import { PencereViewer } from "pencere"
import type { Renderer } from "pencere"
// Custom renderer for a <model-viewer> web component
const modelRenderer: Renderer = {
  canHandle: (item) => item.type === "custom:model",
  mount: (item, { document }) => {
    const el = document.createElement("model-viewer")
    el.setAttribute("src", (item as any).data.url)
    return el
  },
  unmount: (el) => el.remove(),
}
const viewer = new PencereViewer({
  items: [
    { type: "video",  src: "/media/clip.mp4", poster: "/media/clip-poster.jpg", autoplay: true },
    { type: "iframe", src: "https://example.com/embed" },
    { type: "html",   html: () => document.createElement("div") },
  ],
  renderers: [modelRenderer], // custom renderers are checked before built-ins
})

6. When a sources array is present, pencere wraps the <img> in a <picture> element so the browser picks the best format automatically:

new PencereViewer({
  items: [
    {
      type: "image",
      src: "/photos/coast-1600.jpg", // JPEG fallback for legacy browsers
      alt: "Coastal cliffs",
      width: 1600,
      height: 1067,
      srcset: "/photos/coast-800.jpg 800w, /photos/coast-1600.jpg 1600w",
      sizes: "100vw",
      // sources triggers <picture> for automatic AVIF/WebP negotiation
      sources: [
        { type: "image/avif", srcset: "/photos/coast-800.avif 800w, /photos/coast-1600.avif 1600w", sizes: "100vw" },
        { type: "image/webp", srcset: "/photos/coast-800.webp 800w, /photos/coast-1600.webp 1600w", sizes: "100vw" },
      ],
    },
  ],
})

7. Hash-Based Deep Linking:

const viewer = new PencereViewer({
  items,
  routing: true, // writes #p1, #p2, … as the user navigates
})
// On page load, parse the current URL hash and open the matching slide
void viewer.openFromLocation()
// The browser Back button closes the viewer because pencere listens for popstate.
// Customize the hash pattern:
// routing: { pattern: (i) => `#photo/${i + 1}`, parse: (hash) => … }

8. Strict CSP with Nonce:

// Pass your CSP nonce so pencere can stamp the fallback <style> element.
// On Chrome 73+, Firefox 101+, and Safari 16.4+, adoptedStyleSheets are used instead,
// which bypasses style-src entirely. The nonce only applies to older browsers.
new PencereViewer({
  items,
  nonce: document.querySelector<HTMLMetaElement>("meta[name='csp-nonce']")?.content,
})

9. Every visual token is a CSS custom property. Override them anywhere in your cascade:

:root {
  --pc-bg:   oklch(0.14 0.02 240 / 0.96); /* backdrop color and opacity   */
  --pc-fg:   #efefef; /* toolbar and caption text     */
  --pc-font: "Lato", system-ui, sans-serif;
  --pc-focus: #fadc00;  /* focus ring highlight color   */
}

10. All configuration options:

  • items (Item[]): Array of content items to display. Each item requires a type field ("image", "video", "iframe", or "html") and a src or html property.
  • startIndex (number): Zero-based index of the slide to open first. Defaults to 0.
  • loop (boolean): Wraps navigation from the last slide back to the first.
  • container (HTMLElement): Mounts the viewer inside a specific DOM element rather than the document body.
  • strings (Partial<PencereStrings>): Overrides individual UI strings for localization.
  • i18n (function): Full custom translation function that receives a string key and optional interpolation variables.
  • keyboard (object): Controls keyboard behavior. Accepts overrides to remap key bindings and disable to turn off specific actions entirely.
  • image (object): Sets crossOrigin and referrerPolicy on generated <img> elements.
  • reducedMotion ("auto" | "always" | "never"): Controls animation behavior. "auto" reads from prefers-reduced-motion.
  • useNativeDialog (boolean): Renders the viewer inside a native <dialog> element. Defaults to true.
  • lockScroll (boolean): Locks page scroll while the viewer is open.
  • nonce (string): CSP nonce applied to the fallback <style> element on browsers that do not support adoptedStyleSheets.
  • dir ("ltr" | "rtl" | "auto"): Writing direction. "auto" inherits from the host document’s <html dir> attribute.
  • haptics (boolean | HapticsOptions): Opts in to haptic vibration feedback on coarse-pointer devices. No-ops on desktop and iOS Safari.
  • routing (boolean | RoutingOptions): Writes URL hash fragments on slide change and restores the slide on page load.
  • fullscreen (boolean): Exposes fullscreen controls. Falls back to a CSS fixed-position overlay on iOS Safari.
  • viewTransition (boolean): Wraps open() in document.startViewTransition() for native thumbnail morph animation.
  • renderers (Renderer[]): Registers custom content renderers alongside the built-in video, iframe, and html types.

11. API methods:

// Opens the viewer at the specified zero-based index.
// Pass the thumbnail element as the second argument to activate the View Transitions morph.
viewer.open(2, thumbnailElement)

// Closes the viewer programmatically.
viewer.close()

// Navigates to the next slide.
viewer.next()

// Navigates to the previous slide.
viewer.prev()

// Navigates to a specific slide by zero-based index.
viewer.goTo(3)

// Opens the viewer at the slide matching the current URL hash (e.g. #p3 → index 2).
void viewer.openFromLocation()

// Enters fullscreen mode. Requires fullscreen: true in options.
void viewer.enterFullscreen()

// Toggles fullscreen on and off.
void viewer.toggleFullscreen()

12. Lifecycle Events:

// Fires when the active slide index changes
viewer.core.events.on("change", ({ index, item }) => {
  console.log("Now showing slide", index)
})

// Fires when a slide finishes loading its content
viewer.core.events.on("slideLoad", ({ index }) => {
  console.log("Slide loaded", index)
})

// Fires when the viewer closes
// reason: "escape" | "backdrop" | "user" | "api"
viewer.core.events.on("close", ({ reason }) => {
  console.log("Closed via", reason)
})

// Fires before the viewer opens (willOpen lifecycle hook)
viewer.core.events.on("willOpen", () => {
  console.log("Viewer is about to open")
})

// Fires after the first slide finishes rendering
viewer.core.events.on("didRender", () => {
  console.log("First slide rendered")
})

// Fires after slide navigation completes
viewer.core.events.on("didNavigate", ({ index }) => {
  console.log("Navigated to slide", index)
})

React Implementation:

import { useLightbox } from "pencere/react"
function PhotoGallery() {
  const { open } = useLightbox({
    items: [
      { type: "image", src: "/photos/coast.jpg", alt: "Coastal cliffs" },
      { type: "image", src: "/photos/forest.jpg", alt: "Redwood forest" },
    ],
    useNativeDialog: true, // renders inside a native <dialog> element
  })
  return (
    <div>
      <button onClick={() => open(0)}>Open Gallery</button>
    </div>
  )
}

Vue Implementation:

import { usePencere } from "pencere/vue"
// Destructure open() from the composable
const { open } = usePencere({
  items: [
    { type: "image", src: "/photos/coast.jpg", alt: "Coastal cliffs" },
  ],
})
// Call open(index) from a click handler or template event

Svelte Implementation:

<script>
  import { pencere } from "pencere/svelte"
</script>
<!-- The use: directive mounts the viewer reactively on this element -->
<div use:pencere={{ items: [{ type: "image", src: "/photos/coast.jpg", alt: "Coastal cliffs" }] }} />

Web Component Implementation:

<script type="module">
  import { registerPencereElement } from "pencere/element"
  // Registers the <pencere-lightbox> custom element on the current document
  registerPencereElement()
</script>
<pencere-lightbox
  items='[{"src":"/photos/coast.jpg","alt":"Coastal cliffs"}]'
  start-index="0"
></pencere-lightbox>

Alternatives:

The post Accessible Gallery Lightbox for Images, Video, and Iframes – pencere appeared first on CSS Script.


Discover more from RSS Feeds Cloud

Subscribe to get the latest posts sent to your email.

Leave a Reply

Your email address will not be published. Required fields are marked *

Discover more from RSS Feeds Cloud

Subscribe now to keep reading and get access to the full archive.

Continue reading