Subframe
Components & interactionsImage lightbox with zoom

Photo lightbox

An austere photographic index opens a dark, contained viewer with thumbnails, bounded zoom and pan.

Open a photograph; browse, enlarge and drag. Arrow keys browse or pan the enlarged image.

Subframe Original · MIT

Add to your project

Component source

Standalone React component. Styles are included.

React only. No additional packages.

Download images (4)

Save these files in your static assets folder at /motion/assets/, or update their paths in the component.

Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.

View source code
/**
 * MIT License
 * Copyright (c) 2026 Subframe
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */
"use client"
import * as React from "react"

const PHOTOS = [
  {
    src: "/motion/assets/concrete-geometries.webp",
    title: "Structure",
    alt: "Circular concrete towers and angular white architecture",
  },
  { src: "/motion/assets/coastal-break.webp", title: "Tide", alt: "Turquoise surf breaking over ochre coastal rocks" },
  { src: "/motion/assets/running-track.webp", title: "Distance", alt: "Curved red running lanes with white markings" },
  { src: "/motion/assets/tennis-court.webp", title: "Court", alt: "Two green tennis courts seen from directly above" },
]
export default function Example({ active = true }: { active?: boolean }) {
  const [selected, setSelected] = React.useState<number | null>(null),
    [zoom, setZoom] = React.useState(false)
  const [pan, setPan] = React.useState({ x: 0, y: 0 }),
    [keyboard, setKeyboard] = React.useState(false)
  const tiles = React.useRef<(HTMLButtonElement | null)[]>([]),
    close = React.useRef<HTMLButtonElement>(null)
  const drag = React.useRef<{ id: number; x: number; y: number; px: number; py: number } | null>(null)
  const frame = React.useRef<HTMLDivElement>(null),
    previous = React.useRef(0)
  function choose(i: number) {
    setSelected((i + PHOTOS.length) % PHOTOS.length)
    setZoom(false)
    setPan({ x: 0, y: 0 })
  }
  function dismiss() {
    setSelected(null)
    setZoom(false)
    setPan({ x: 0, y: 0 })
    requestAnimationFrame(() => tiles.current[previous.current]?.focus())
  }
  const open = selected !== null
  React.useEffect(() => {
    if (open) close.current?.focus()
  }, [open])
  React.useEffect(() => {
    if (!active) drag.current = null
  }, [active])
  function clampPan(x: number, y: number) {
    const box = frame.current,
      img = box?.querySelector("img")
    if (!box || !img?.naturalWidth) return { x: 0, y: 0 }
    const scale = Math.min(box.clientWidth / img.naturalWidth, box.clientHeight / img.naturalHeight)
    const maxX = Math.max(0, (img.naturalWidth * scale * 1.7 - box.clientWidth) / 2),
      maxY = Math.max(0, (img.naturalHeight * scale * 1.7 - box.clientHeight) / 2)
    return { x: Math.max(-maxX, Math.min(maxX, x)), y: Math.max(-maxY, Math.min(maxY, y)) }
  }
  React.useEffect(() => {
    const el = frame.current
    if (!el || !zoom) return
    const ro = new ResizeObserver(() => setPan((p) => clampPan(p.x, p.y)))
    ro.observe(el)
    return () => ro.disconnect()
  }, [open, zoom])
  function move(e: React.PointerEvent) {
    const d = drag.current
    if (!d) return
    const element = frame.current!
    const bounds = element.getBoundingClientRect()
    const dx = ((e.clientX - d.x) * element.clientWidth) / bounds.width
    const dy = ((e.clientY - d.y) * element.clientHeight) / bounds.height
    setPan(clampPan(d.px + dx, d.py + dy))
  }
  return (
    <div
      className="sf-photo-lightbox"
      data-quiet={!active || keyboard}
      onPointerDownCapture={() => setKeyboard(false)}
      onKeyDownCapture={() => setKeyboard(true)}
    >
      <div className="pl-contact" inert={selected !== null ? true : undefined}>
        <div className="pl-grid">
          {PHOTOS.map((p, i) => (
            <button
              ref={(el) => {
                tiles.current[i] = el
              }}
              key={p.src}
              className="pl-tile"
              onClick={() => {
                previous.current = i
                choose(i)
              }}
              aria-label={`Open ${p.title}`}
            >
              <img src={p.src} alt={p.alt} draggable={false} />
              <span>
                {p.title}
                <i>↗</i>
              </span>
            </button>
          ))}
        </div>
      </div>
      {selected !== null && (
        <div
          role="dialog"
          aria-label={`${PHOTOS[selected].title} photo viewer`}
          className="pl-dialog"
          onKeyDown={(e) => {
            if (e.key === "Escape") {
              e.preventDefault()
              dismiss()
            }
            if (zoom && ["ArrowRight", "ArrowLeft", "ArrowUp", "ArrowDown"].includes(e.key)) {
              e.preventDefault()
              setPan(
                clampPan(
                  pan.x + (e.key === "ArrowLeft" ? 30 : e.key === "ArrowRight" ? -30 : 0),
                  pan.y + (e.key === "ArrowUp" ? 30 : e.key === "ArrowDown" ? -30 : 0),
                ),
              )
            } else if (e.key === "ArrowRight") {
              e.preventDefault()
              choose(selected + 1)
            } else if (e.key === "ArrowLeft") {
              e.preventDefault()
              choose(selected - 1)
            }
            if (e.key === "Tab") {
              const b = Array.from(e.currentTarget.querySelectorAll<HTMLButtonElement>("button")).filter(
                (button) => button.offsetParent !== null,
              )
              const first = b[0],
                last = b[b.length - 1]
              if (e.shiftKey && document.activeElement === first) {
                e.preventDefault()
                last.focus()
              }
              if (!e.shiftKey && document.activeElement === last) {
                e.preventDefault()
                first.focus()
              }
            }
          }}
        >
          <header>
            <span>
              {PHOTOS[selected].title} <small>{selected + 1} / 4</small>
            </span>
            <button ref={close} onClick={dismiss} aria-label="Close photo">
              ×
            </button>
          </header>
          <div
            ref={frame}
            className="pl-view"
            tabIndex={0}
            aria-label="Photo canvas"
            data-zoom={zoom}
            onPointerDown={(e) => {
              if (!zoom || !active || e.button !== 0) return
              e.currentTarget.focus({ preventScroll: true })
              e.currentTarget.setPointerCapture(e.pointerId)
              drag.current = { id: e.pointerId, x: e.clientX, y: e.clientY, px: pan.x, py: pan.y }
            }}
            onPointerMove={move}
            onPointerUp={() => {
              drag.current = null
            }}
            onPointerCancel={() => {
              drag.current = null
            }}
            onLostPointerCapture={() => {
              drag.current = null
            }}
          >
            <img
              src={PHOTOS[selected].src}
              alt={PHOTOS[selected].alt}
              draggable={false}
              style={{ transform: `translate(${pan.x}px,${pan.y}px) scale(${zoom ? 1.7 : 1})` }}
            />
          </div>
          <footer>
            <button onClick={() => choose(selected - 1)} aria-label="Previous photo">
              ←
            </button>
            <div className="pl-thumbs">
              {PHOTOS.map((p, i) => (
                <button
                  key={p.src}
                  aria-label={`View ${p.title}`}
                  aria-pressed={selected === i}
                  onClick={() => choose(i)}
                >
                  <img src={p.src} alt="" draggable={false} />
                </button>
              ))}
            </div>
            <button
              aria-label={zoom ? "Fit photo" : "Enlarge photo"}
              aria-pressed={zoom}
              onClick={() => {
                setZoom(!zoom)
                setPan({ x: 0, y: 0 })
              }}
            >
              {zoom ? "−" : "+"}
            </button>
            <button onClick={() => choose(selected + 1)} aria-label="Next photo">
              →
            </button>
          </footer>
        </div>
      )}
      <style>{`.sf-photo-lightbox{position:relative;width:100%;height:100%;min-height:280px;overflow:hidden;background:#f7f7f4;color:#17241c;font-family:Arial,sans-serif;container-type:size}.sf-photo-lightbox *{box-sizing:border-box}.sf-photo-lightbox button{font:inherit;cursor:pointer;touch-action:manipulation}.sf-photo-lightbox button:focus-visible{outline:2px solid #d04620;outline-offset:3px}.sf-photo-lightbox .pl-contact{padding:20px;height:100%}.sf-photo-lightbox header{display:flex;justify-content:space-between;align-items:center;height:30px;font-size:10px;letter-spacing:.06em}.sf-photo-lightbox header>span:last-child{color:#596259}.sf-photo-lightbox .pl-grid{height:100%;display:grid;grid-template-columns:1.15fr 1fr 1fr 1fr;gap:8px;align-items:center}.sf-photo-lightbox .pl-tile{display:flex;flex-direction:column;padding:0;border:0;background:none;min-width:0;height:88%;text-align:left}.sf-photo-lightbox .pl-tile:first-child{height:100%}.sf-photo-lightbox .pl-tile:nth-child(3){height:76%}.sf-photo-lightbox .pl-tile img{width:100%;height:calc(100% - 25px);object-fit:cover;transition:filter .2s}.sf-photo-lightbox .pl-tile:hover img{filter:brightness(.94)}.sf-photo-lightbox .pl-tile span{display:flex;justify-content:space-between;padding-top:8px;font-size:10px}.sf-photo-lightbox .pl-tile i{font-style:normal}.sf-photo-lightbox .pl-dialog{position:absolute;inset:0;background:#102419;color:#fff;padding:8px 14px 10px;display:flex;flex-direction:column;z-index:3}.sf-photo-lightbox .pl-dialog header{height:44px;flex-shrink:0;font-size:12px;letter-spacing:0}.sf-photo-lightbox .pl-dialog header small{font:10px monospace;color:#9daf9f;margin-left:12px}.sf-photo-lightbox .pl-dialog button{border:0;min-width:44px;height:44px;background:transparent;color:#fff;font-size:22px}.sf-photo-lightbox .pl-dialog header>button{margin-right:-10px}.sf-photo-lightbox .pl-view{flex:1;min-height:0;position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;touch-action:pan-y}.sf-photo-lightbox .pl-view[data-zoom=true]{touch-action:none;cursor:grab}.sf-photo-lightbox .pl-view img{height:100%;width:100%;object-fit:contain;user-select:none;transition:transform .24s cubic-bezier(.2,.8,.2,1)}.sf-photo-lightbox .pl-view[data-zoom=true] img{transition:none}.sf-photo-lightbox footer{display:flex;align-items:center;justify-content:center;gap:3px;flex-shrink:0;padding-top:8px}.sf-photo-lightbox .pl-thumbs{display:flex;gap:4px;margin:0 auto}.sf-photo-lightbox .pl-thumbs button{min-width:44px;width:44px;height:44px;padding:3px;border:1px solid transparent;opacity:.55}.sf-photo-lightbox .pl-thumbs button[aria-pressed=true]{opacity:1;border-color:#ddd}.sf-photo-lightbox .pl-thumbs img{width:100%;height:100%;object-fit:cover}.sf-photo-lightbox[data-quiet=true] *{transition:none!important}@container(max-width:400px){.sf-photo-lightbox .pl-contact{padding:14px}.sf-photo-lightbox .pl-grid{grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;gap:12px 8px}.sf-photo-lightbox .pl-tile,.sf-photo-lightbox .pl-tile:first-child,.sf-photo-lightbox .pl-tile:nth-child(3){height:100%}.sf-photo-lightbox .pl-tile span{font-size:9px;padding-top:5px}.sf-photo-lightbox .pl-tile img{height:calc(100% - 19px)}.sf-photo-lightbox .pl-dialog{padding-inline:8px}.sf-photo-lightbox .pl-thumbs{gap:1px}.sf-photo-lightbox .pl-thumbs button{min-width:44px;width:44px}.sf-photo-lightbox footer>button:first-child,.sf-photo-lightbox footer>button:last-child{display:none}}@media(prefers-reduced-motion:reduce){.sf-photo-lightbox *{transition:none!important}}`}</style>
    </div>
  )
}

Where it works well

Inspect a photograph closely without losing its collection context.

Reduced motion

The viewer opens immediately and fit-to-image changes skip interpolation.

Things to know

The viewer is scoped to its component bounds, not a page-level modal. Photographs have separate Unsplash terms.

More examples