How to build an image, SVG, and code lightbox with zoom and keyboard support
Code samples get cramped on a phone. Diagrams have tiny labels. Screenshots beg to be seen bigger. The fix is a lightbox — a full-screen overlay that opens a single piece of content and lets you zoom and pan around it.
This blog has one that works for three kinds of content — images, SVG diagrams,
and code blocks — with mouse, wheel, drag, and keyboard support, and no
third-party library. It all lives in one client <script> in
PostEnhancements.astro. Let me build it up the same way the real file does.
The plan
Everything routes through one function:
openLightbox(html: string, kind: "image" | "svg" | "code")You hand it a chunk of HTML and a “kind” label, and it drops that HTML onto a shared overlay. Because there’s exactly one overlay reused for everything, the zoom/pan/keyboard logic only has to be written once. The three content types differ only in what HTML they pass in.
flowchart LR A[click image] --> D[openLightbox] B[click diagram] --> D C[click expand on code] --> D D --> E[shared overlay: zoom / pan / keys]
Wiring up the triggers
Each content type gets its own small “wire” function that finds the elements and attaches a click handler. Images are the simplest:
// blog/src/components/PostEnhancements.astro
function wireImages() {
document.querySelectorAll<HTMLImageElement>(".prose img").forEach((img) => {
if (img.dataset.wired) return
img.dataset.wired = "1"
img.classList.add("is-zoomable")
img.addEventListener("click", () =>
openLightbox(
`<img src="${img.currentSrc || img.src}" alt="${img.alt.replace(/"/g, """)}" />`,
"image",
),
)
})
}Two beginner-friendly details here. The img.dataset.wired guard means an image
never gets two click handlers even if the init runs twice — a cheap idempotency
trick. And img.alt.replace(/"/g, """) escapes any double-quotes in the alt
text so they don’t break the HTML string we’re building.
Code blocks pass different HTML. When you click the “expand” button the rehype
toolbar plugin adds to each block, it clones the highlighted <pre> into the overlay:
function wireExpandButtons() {
document.querySelectorAll<HTMLButtonElement>("[data-code-expand]").forEach((btn) => {
if (btn.dataset.wired) return
btn.dataset.wired = "1"
btn.addEventListener("click", () => {
const pre = btn.closest(".code-block")?.querySelector("pre")
if (!pre) return
openLightbox(
`<div class="lightbox-code"><pre class="astro-code">${pre.innerHTML}</pre></div>`,
"code",
)
})
})
}SVG diagrams (rendered from Mermaid in MDX) call openLightbox(holder.innerHTML, "svg")
the same way. Same function, three sources.
The shared overlay
The overlay is built once, lazily, the first time it’s needed. A module-level
overlay variable caches it so we don’t rebuild it on every open:
let overlay: HTMLDivElement | null = null
let stage: HTMLDivElement | null = null
let scale = 1
let tx = 0
let ty = 0
function ensureOverlay() {
if (overlay) return
overlay = document.createElement("div")
overlay.className = "lightbox"
overlay.setAttribute("role", "dialog")
overlay.setAttribute("aria-modal", "true")
overlay.innerHTML = `
<div class="lightbox__bar">
<div class="lightbox__zoom" data-kind="">
<button type="button" data-lb="out" aria-label="Zoom out">–</button>
<button type="button" data-lb="reset" aria-label="Reset zoom">reset</button>
<button type="button" data-lb="in" aria-label="Zoom in">+</button>
</div>
<button type="button" class="lightbox__close" data-lb="close" aria-label="Close">esc ✕</button>
</div>
<div class="lightbox__stage" data-lb-stage></div>`
document.body.appendChild(overlay)
stage = overlay.querySelector<HTMLDivElement>("[data-lb-stage]")
// ...event listeners attached here (below)
}Notice role="dialog" and aria-modal="true" — that tells screen readers this
is a modal dialog, not just a floating div. Every button has an aria-label
too. The three state variables — scale, tx, ty — track the current zoom
level and the x/y pan offset.
Zoom
Zooming is just changing scale and re-applying a CSS transform. The whole
thing is two tiny functions:
function applyTransform() {
if (stage) stage.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`
}
function setZoom(next: number, reset = false) {
scale = Math.min(6, Math.max(0.25, next))
if (reset || scale === 1) {
scale = 1
tx = 0
ty = 0
}
stage?.classList.toggle("is-zoomed", scale > 1)
applyTransform()
}setZoom clamps the scale between 0.25× and 6× so you can never zoom into
nothing or out to infinity. When zoom returns to 1× (or reset is passed) the
pan offsets snap back to zero, so closing and reopening always starts clean.
The toolbar buttons and the mouse wheel both call setZoom:
overlay.addEventListener("click", (e) => {
const t = e.target as HTMLElement
const action = t.closest("[data-lb]")?.getAttribute("data-lb")
if (action === "close" || t === overlay) return closeLightbox()
if (action === "in") return setZoom(scale + 0.25)
if (action === "out") return setZoom(scale - 0.25)
if (action === "reset") return setZoom(1, true)
})
overlay.addEventListener("wheel", (e) => {
if (!overlay!.classList.contains("is-open")) return
e.preventDefault()
setZoom(scale + (e.deltaY < 0 ? 0.15 : -0.15))
}, { passive: false })Clicking the backdrop itself (t === overlay) closes the lightbox — a common,
expected gesture. { passive: false } on the wheel listener is required because
we call e.preventDefault() to stop the page scrolling behind the overlay.
Drag to pan
Panning only makes sense when you’re zoomed in, so the drag handler bails early
if scale <= 1. It records where the drag started, then updates the offset on
every pointer move:
overlay.addEventListener("pointerdown", (e) => {
if (!stage!.contains(e.target as Node) || scale <= 1) return
drag = { x: e.clientX, y: e.clientY, tx, ty }
stage!.classList.add("is-grabbing")
})
window.addEventListener("pointermove", (e) => {
if (!drag) return
tx = drag.tx + (e.clientX - drag.x)
ty = drag.ty + (e.clientY - drag.y)
applyTransform()
})
window.addEventListener("pointerup", () => {
drag = null
stage?.classList.remove("is-grabbing")
})The math is just “new offset = offset when the drag started + how far the
pointer moved.” Using pointer events (not mousedown/touchstart) means
one code path covers mouse and touch.
Keyboard controls
Accessibility and speed both want keys. One keydown listener, guarded so it
only fires while the overlay is open:
window.addEventListener("keydown", (e) => {
if (!overlay?.classList.contains("is-open")) return
if (e.key === "Escape") closeLightbox()
if (e.key === "+" || e.key === "=") setZoom(scale + 0.25)
if (e.key === "-") setZoom(scale - 0.25)
if (e.key === "0") setZoom(1, true)
})Esc closes, +/- zoom, 0 resets. Familiar shortcuts, no surprises.
Open and close (and focus)
Finally, the open/close pair. Opening stashes the currently focused element, resets state, injects the HTML, locks page scroll, and moves focus to the close button. Closing restores everything:
let lastFocus: HTMLElement | null = null
function openLightbox(html: string, kind: "image" | "svg" | "code") {
ensureOverlay()
lastFocus = document.activeElement as HTMLElement
scale = 1; tx = 0; ty = 0
stage!.innerHTML = html
stage!.className = `lightbox__stage lightbox__stage--${kind}`
overlay!.classList.add("is-open")
document.documentElement.style.overflow = "hidden"
overlay!.querySelector<HTMLElement>(".lightbox__close")?.focus()
}
function closeLightbox() {
overlay?.classList.remove("is-open")
document.documentElement.style.overflow = ""
if (stage) stage.innerHTML = ""
lastFocus?.focus?.()
}Saving lastFocus and returning to it on close is a small but important
accessibility touch — keyboard users don’t get dumped back at the top of the
page. Setting overflow: hidden on the root element freezes the page behind the
modal. The lightbox__stage--${kind} class lets CSS style images, SVG, and code
differently while sharing the same behavior.
What to remember
- Route every content type through one
openLightbox(html, kind)and reuse a single overlay — write the hard parts (zoom, pan, keys) once. - Zoom is just a clamped
scalefed into a CSS transform; pan is start-offset plus pointer delta, only enabled when zoomed in. - Use pointer events so mouse and touch share one code path.
- Guard global listeners so they only act while the overlay is open.
- For accessibility:
role="dialog",aria-modal,aria-labeled controls, lock page scroll, and restore focus on close.