ehsan.blog
~/blog/how-to-build-an-image-and-code-lightbox-in-astro — zsh
cat how-to-build-an-image-and-code-lightbox-in-astro.md

How to build an image, SVG, and code lightbox with zoom and keyboard support

·6 min read

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:

ts
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:

ts
// 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, "&quot;")}" />`,
        "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, "&quot;") 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:

ts
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:

ts
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:

ts
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:

ts
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:

ts
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:

ts
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:

ts
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 scale fed 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.
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.