pencere
A modern, accessible, framework-agnostic image lightbox in pure TypeScript. Zero runtime dependencies, pure ESM, tree-shakeable.
# 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
- Swipe left / right — navigate between images at fit scale.
- Swipe down — drag-to-dismiss with backdrop fade.
- Pinch — zoom around the centroid, clamped to 1×–8×.
- Double-tap — toggle 1× ↔ 2× zoom.
- Wheel — exponential zoom at the cursor; snaps back at 1×.
# Accessibility
-
Native
<dialog>+showModal()for top-layer focus trap. - ARIA APG Dialog (Modal) + Carousel pattern; every control has an accessible name.
aria-live="polite"slide announcements.- Focus returns to the invoking element on close.
- Honors
prefers-reduced-motion. - IME-safe keyboard: ignores events during composition.
- CloseWatcher integration — Android back button closes like Escape.
# 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:
-
Layout flips via
inset-inline-start/end— prev/next buttons swap sides automatically. - ← advances to the next slide, → goes back.
- Horizontal swipes flip so dragging toward the end of the reading flow is always "next".
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';
-
No inline styles. On modern engines pencere attaches its stylesheet
through
adoptedStyleSheets, which bypassesstyle-srcentirely. Older browsers get a fallback<style nonce="...">element — pass the same nonce vianew PencereViewer({ nonce }). -
Runtime values via custom properties. Transform, opacity and aspect
ratio are written through
style.setProperty("--pc-*", ...), neverstyle.cssText, so no inlinestyle=""attribute is ever generated. -
Trusted Types ready. pencere itself renders captions with
textContent. If you opt into HTML captions:
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
- Zero runtime dependencies — no supply-chain surface.
-
URL protocol allowlist (
http,https,data,blob).javascript:,vbscript:,file:rejected — including whitespace-smuggling variants. -
All user-supplied strings rendered via
textContent, neverinnerHTML. Opt into HTML captions with the Trusted Types helper above. referrerpolicy="strict-origin-when-cross-origin"by default.-
SVGs are rendered via
<img src>only — the spec sandbox blocks scripts and external fetches from inside the file. -
npm releases published with
--provenance(SLSA attestation) and SRI hashes in every GitHub release note.