Media

Image Zoom

A clickable thumbnail that opens a full-screen lightbox — fade + scale transition, click/Esc to close, focus managed.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { createPortal } from "react-dom"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"

// Close transition (ms), kept in step with duration-200 below — it decides when the exit animation is
// done and the lightbox layer can unmount
const CLOSE_TRANSITION_MS = 200

/**
 * The lock count and the original-style snapshot live in `document.body` data attributes, not in module
 * scope. This component may be installed on its own next to other overlays, each carrying its own copy of

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/image-zoom.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ImageZoom" component (lucide-react for
the close icon, react-dom's createPortal).

Contract
- Export a plain function component (no forwardRef needed — the trigger is
  internal). Props: src: string, alt: string, zoomSrc?: string (the
  full-resolution image shown in the lightbox, defaults to src so a small
  thumbnail can still open a much larger image), className?: string (merged
  onto the thumbnail <img> only, via cn()).

Behavior
- The thumbnail renders inside a <button aria-label="Zoom image"> so it's
  keyboard-reachable and announced correctly; clicking it opens the lightbox.
- State machine: a "mounted" boolean (is the lightbox in the DOM at all,
  including while its exit transition plays) and an "open" boolean (is it in
  the visually-open state, driving a data-state="open"|"closed" attribute).
  Opening sets mounted true, then flips open true on the next animation
  frame so the browser paints the closed state first and the transition has
  something to animate from. Closing flips open false immediately and
  focuses the trigger button back; after the CSS transition duration (or
  immediately if prefers-reduced-motion is set) it flips mounted false,
  unmounting the portal.
- Close triggers: clicking the backdrop, clicking the image itself (no
  special-casing needed — the image sits inside the backdrop's onClick, so
  the click bubbles), the dedicated close button, and Escape — a window
  keydown listener attached only while open, removed on cleanup / when open
  flips false.
- Body scroll lock while open — the reentrancy count AND the pre-lock
  snapshot live on `document.body` as data attributes
  (`body.dataset.zyScrollLocks`, `.zyScrollLockOverflow`,
  `.zyScrollLockPadding`), never per instance and never in module-level
  variables. The 0 → 1 edge snapshots body's current inline `overflow` /
  `paddingRight` (the current values, not a hardcoded "") and freezes; later
  locks only increment; only the 1 → 0 release restores the snapshot and
  deletes all three attributes. The obvious `const previousOverflow =
  document.body.style.overflow` per instance is the bug this replaces: every
  component here is installed as its own copy, so one page runs several
  independent copies of this same lock (this lightbox, a gallery lightbox, a
  drawer). Open two and the second reads "hidden" as the "original"; close the
  first and it restores "", close the second and it writes "hidden" back — the
  page is frozen until a reload, with no overlay left on screen to explain it.
  A module-level counter does not fix it either, because independent copies get
  independent module scopes. A body attribute is the one namespace they already
  share; keep the three names byte-identical wherever this code is pasted.
- Scrollbar compensation is MEASURED, not predicted: read
  `document.documentElement.clientWidth`, set `overflow: hidden`, read it
  again, and add the positive difference to body's computed `paddingRight`.
  The `innerWidth - clientWidth` shortcut is wrong on any page with
  `scrollbar-gutter: stable` — the gutter is permanent, no width is reclaimed,
  and padding ~15px anyway shifts the page LEFT exactly as the lightbox fades
  in. The measurement yields 0 under macOS overlay scrollbars, so a Mac-only
  test proves nothing about this branch.
- Clear any pending close timeout on unmount so it never fires after the
  component is gone.
- On open, move focus into the close button; on close, return focus to the
  trigger button (stored in a ref) — the round trip must work with keyboard
  only (Tab to thumbnail, Enter to open, Esc to close, focus lands back on
  the thumbnail).
- SSR guard: createPortal needs `document`, so track an "is client" flag via
  useSyncExternalStore (subscribe is a no-op — this value never changes
  after first client paint) with a false server snapshot, and only render
  the portal when both that flag and "mounted" are true.

Rendering & styling
- Thumbnail: <img className="rounded-lg"> inside the trigger button, which
  gets cursor-zoom-in and a focus-visible:ring-2 ring-ring ring-offset-2.
- Lightbox portal (mounted to document.body): fixed inset-0 z-50 flex
  items-center justify-center bg-background/80 p-4 backdrop-blur-sm,
  role="dialog" aria-modal="true" aria-label={alt}. Opacity transitions
  0 → 100 driven by the open boolean (transition-opacity duration-200,
  motion-reduce:transition-none — reduced-motion users get an instant cut,
  not a fade).
- Zoomed image: max-h-[90vh] max-w-[90vw] object-contain cursor-zoom-out,
  scale-95 → scale-100 on the same open boolean (transition-transform
  duration-200, motion-reduce:transition-none).
- Close button: absolute right-4 top-4, rounded-full bg-background/60
  hover:bg-background/80, holding a lucide X icon (aria-hidden, the button
  itself carries aria-label="Close zoomed image").
- Semantic tokens only: bg-background, text-foreground, ring-ring — no
  hardcoded colors.

Customization levers
- Backdrop strength: bg-background/80 + backdrop-blur-sm — raise/lower the
  opacity fraction or drop the blur for a lighter overlay.
- Transition duration: the 200ms duration-200 class and the CLOSE_TRANSITION_MS
  constant must stay in sync (the constant gates when the portal unmounts) —
  change both together; swap scale-95 for a smaller/larger start scale to
  tune the "pop" intensity.
- Gallery / prev-next navigation: out of scope for this component by design
  (see "not_when") — wiring arrow-key navigation across multiple images
  means turning the lightbox into a carousel: add an images array prop,
  track a current index instead of a single src, and extend the keydown
  handler with ArrowLeft/ArrowRight.
- Zoom trigger style: swap cursor-zoom-in/cursor-zoom-out for a magnifier
  icon overlay on hover if you want an affordance beyond the cursor change.
  Do not repurpose this component into a hover-following lens (a cursor-
  tracked magnified glass over the image) — that is a different interaction
  mode entirely (no click, no lightbox, no portal) and belongs in a separate
  component built around onMouseMove + a clipped/scaled background layer.

Concepts

  • Portal-mounted overlay — the lightbox renders via createPortal into document.body, so it escapes any parent overflow/z-index stacking context; an SSR guard keeps it out of the server render entirely.
  • Two-flag transitionmounted controls DOM presence (including the exit animation window), open controls the visual data-state; opening mounts first and flips open a frame later so the enter transition has a starting point to animate from.
  • Focus round-trip — opening moves focus to the close button, closing returns it to the trigger — keyboard users never lose their place.
  • Scroll lock counted on document.body — the count and the saved overflow / paddingRight are data attributes on body, not a per-instance previousOverflow and not module-level variables, because every component here is installed as its own copy: a gallery lightbox or a drawer on the same page runs a different copy of the identical lock with its own private state. Two blind copies restore each other's values — the first closes and writes back "", the second closes and writes back the "hidden" it believed was the original — and the page never scrolls again. A DOM attribute is the one namespace independent copies already share.
  • Measured, not predicted, scrollbar compensation — the padding added while locked is the observed clientWidth difference across the overflow: hidden write, not innerWidth - clientWidth. Under scrollbar-gutter: stable the gutter never goes away, so the prediction pads width that was never reclaimed and the page jumps left as the lightbox opens. It also means the branch is a no-op on macOS overlay scrollbars — the reason this class of bug survives Mac-only testing.
  • Reduced-motion escape hatchprefers-reduced-motion skips the fade/scale entirely: the lightbox appears and disappears instantly instead of transitioning.

On This Page