pencere

A modern, accessible, framework-agnostic image lightbox in pure TypeScript. Zero runtime dependencies, pure ESM, tree-shakeable.

MIT zero deps pure ESM WCAG 2.2 AA ~15 kB gz

# Install

pnpm add pencere
# or
npm i pencere

# Live demo

Click any thumbnail to open the viewer. Try / to navigate, + / - / 0 to zoom, Esc to close. Double-click or double-tap the image to zoom to 2×. Scroll the mouse wheel to zoom at the cursor.

# Quick start

import { PencereViewer } from "pencere"

const viewer = new PencereViewer({
  items: [
    { type: "image", src: "/a.jpg", alt: "Mountain lake", width: 1600, height: 1067 },
    { type: "image", src: "/b.jpg", alt: "River valley", width: 1600, height: 1067 },
  ],
  loop: true,
})

document.querySelector("#open")?.addEventListener("click", () => {
  void viewer.open(0)
})

# Keyboard

Key Action
Esc Close the viewer (also respects Android back via CloseWatcher)
/ PageUp Previous image
/ PageDown Next image
Home / End Jump to first / last
+ / - Zoom in / out by 1.25×
0 Reset zoom

# Gestures

# Accessibility

# Theming

Every color, font and focus ring is a CSS custom property. Drop these variables anywhere in your cascade — pencere never injects inline styles so your overrides always win.

:root {
  --pc-bg: oklch(0.16 0.02 260 / 0.94);
  --pc-fg: #f5f5f5;
  --pc-font: "Inter", system-ui, sans-serif;
  --pc-focus: #facc15;
}

Under @media (forced-colors: active) pencere automatically swaps to system color keywords (Canvas, ButtonFace, Highlight, GrayText) so Windows High Contrast users see a legible UI with zero configuration.

# Right-to-left

pencere inherits direction from <html dir> (or any ancestor). You can also force it per viewer via the dir option. Under RTL:

Try it live — toggle the whole page to RTL and reopen the lightbox:

currently: ltr

# Remap keyboard shortcuts

Every key binding is data-only. Override any action, disable any key, or add a second binding without touching the event pipeline:

new PencereViewer({
  items,
  keyboard: {
    overrides: {
      close: ["Escape", "q"],
      next: ["ArrowRight", "l"],     // vim-style
      prev: ["ArrowLeft", "h"],
    },
    disable: ["toggleSlideshow"],    // space scrolls the page instead
  },
})

All shortcuts are IME-safe: while a Japanese, Korean or Chinese user is confirming conversion, isComposing suppresses every pencere binding so Enter and Esc do not close the viewer by accident.

# Content Security Policy

pencere is written to run under the strictest CSP profile you can ship. The minimum header it needs:

Content-Security-Policy:
  default-src 'self';
  img-src 'self' https: data: blob:;
  style-src 'self' 'nonce-RANDOM';
  script-src 'self' 'nonce-RANDOM';
  trusted-types pencere;
  require-trusted-types-for 'script';
import DOMPurify from "dompurify"
import { createTrustedTypesPolicy } from "pencere"

const policy = createTrustedTypesPolicy({
  sanitize: (html) => DOMPurify.sanitize(html),
})
el.innerHTML = policy.createHTML(userHtml)

# Events

viewer.core.events.on("open",    ({ index })       => { /* ... */ })
viewer.core.events.on("change",  ({ index, item }) => {
  history.replaceState(null, "", `#p${index + 1}`)
})
viewer.core.events.on("slideLoad", ({ index })     => { /* analytics */ })
viewer.core.events.on("close",     ({ reason })    => {
  // "escape" | "backdrop" | "user" | "api"
})

# More features

Every snippet below is wired up in the demo above — open DevTools to watch them fire.

Declarative HTML via bindPencere()

<a href="/a.jpg" data-pencere data-gallery="trip" data-caption="Day 1">
  <img src="/a-thumb.jpg" alt="Mountain lake" />
</a>

<script type="module">
  import { bindPencere } from "pencere"
  bindPencere("[data-pencere]")
</script>

Live demo — the two thumbnails below are declarative markup:

Hash-based deep linking

const viewer = new PencereViewer({ items, routing: true })
// /gallery#p3 opens slide index 2 automatically:
void viewer.openFromLocation()

Browser Back and Safari / Firefox edge-swipe back gestures close the viewer natively via popstate.

Thumbnail → lightbox morph

new PencereViewer({ items, viewTransition: true })
// pass the clicked thumb as the trigger so the UA morphs natively:
void viewer.open(index, thumbEl)

Live demo — click this thumbnail to watch the UA morph the image from its resting position into the lightbox (Chrome / Edge / Safari Tech Preview):

Fullscreen with iOS fallback

const viewer = new PencereViewer({ items, fullscreen: true })
await viewer.toggleFullscreen()

Uses requestFullscreen() when available, falls back to a CSS class pinning the root with position: fixed; inset: 0; height: 100dvh on iOS Safari.

Responsive <picture> with AVIF / WebP

{
  type: "image",
  src: "/a-1600.jpg",
  srcset: "/a-800.jpg 800w, /a-1600.jpg 1600w",
  sizes: "100vw",
  sources: [
    { type: "image/avif", srcset: "/a-800.avif 800w, /a-1600.avif 1600w" },
    { type: "image/webp", srcset: "/a-800.webp 800w, /a-1600.webp 1600w" },
  ],
}

ThumbHash / BlurHash placeholder

{
  type: "image",
  src: "/a.jpg",
  placeholder: "url(data:image/png;base64,…)",
  // or a CSS gradient / solid color — any background value works.
}

Arrow-key pan while zoomed (WCAG 2.5.7)

Zoom in with +, then use to pan the image without dragging. Reset with 0.

Video / iframe / custom renderers

new PencereViewer({
  items: [
    { type: "video",  src: "/clip.mp4", poster: "/clip.jpg", autoplay: true },
    { type: "iframe", src: "https://example.com/embed" },
    { type: "custom:text", data: { title, body } },
  ],
  renderers: [textRenderer], // user renderers run ahead of built-ins
})

Built-in renderers cover video, iframe, and html. Plug in your own via renderers: [...]. User renderers always run ahead of the built-ins so you can override any default. Click through each:

Haptic feedback

new PencereViewer({ items, haptics: true })

Opt-in only. Gated on matchMedia('(any-pointer: coarse)') so desktop trackpads never buzz, and no-ops on iOS Safari which does not expose the Vibration API. Fires on swipe-to-dismiss, wheel-zoom snap-back, and double-tap toggles.

# Security