
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
adoptedStyleSheetsand 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, andsourcesfor 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 atypefield ("image","video","iframe", or"html") and asrcorhtmlproperty.startIndex(number): Zero-based index of the slide to open first. Defaults to0.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. Acceptsoverridesto remap key bindings anddisableto turn off specific actions entirely.image(object): SetscrossOriginandreferrerPolicyon generated<img>elements.reducedMotion("auto"|"always"|"never"): Controls animation behavior."auto"reads fromprefers-reduced-motion.useNativeDialog(boolean): Renders the viewer inside a native<dialog>element. Defaults totrue.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 supportadoptedStyleSheets.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): Wrapsopen()indocument.startViewTransition()for native thumbnail morph animation.renderers(Renderer[]): Registers custom content renderers alongside the built-invideo,iframe, andhtmltypes.
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 eventSvelte 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.
