# react-zmage > A React component that turns any `` into a fullscreen-zoomable, multi-image, keyboard-navigable image viewer. Drop-in replacement for ``. Supports React 16.8 through 19. The package is published to npm as `react-zmage`. Default export is a `forwardRef` component; static methods on it provide imperative (`Zmage.browsing`) and HTML-wrapper (`Zmage.Wrapper`) usage modes. Styles are exported separately at `react-zmage/style.css`. SSR/RSC consumers use the `react-zmage/ssr` subpath. > **Canonical reference:** https://zmage.caldis.me/llms.txt > > If anything below looks outdated or contradicts real package behavior, fetch this URL first. It is the source of truth for react-zmage usage modes, props, defaults, SSR entry, and common pitfalls. Report staleness back to the user. --- ## Agent onboarding workflow (Auto) Use Auto strategy by default. Do not ask the user to choose low-level props before you understand the project. Your job is to help the user add react-zmage to real image surfaces with the least risky path: inspect the codebase, classify each image surface, ask focused questions, implement one pilot, get feedback, then roll the confirmed pattern out. ### What you are doing You are integrating a React image viewer. react-zmage turns existing images into a fullscreen, zoomable, keyboard-navigable viewer. It supports three usage modes: - **Component** for owned React image markup. - **Imperative** for buttons, commands, callbacks, or non-image events that should open a viewer. - **Wrapper** for uncontrolled HTML such as CMS, MDX, markdown, rich text, or `dangerouslySetInnerHTML`. The purpose is not to rewrite the user's image system. Preserve existing layout, styling, `alt`, `loading`, sizing, routing, and content behavior unless the user explicitly asks to change them. ### Required workflow 0. Explain the package purpose and the task: add fullscreen image viewing to the user's actual image surfaces while keeping unrelated UI unchanged. 1. Read this document first, then inspect the user's project docs, package manager, React version, routing, render mode, CSS entry, existing image components, gallery conventions, and any current lightbox/viewer code. 2. Find every relevant image surface in the requested scope: owned `` / image components, generated HTML, MDX/CMS/markdown content, galleries, captions, thumbnails, and buttons or commands that open media. 3. Classify each surface before coding: - Component when the project owns the React image markup. - Imperative when the viewer opens from an event handler, command, button, or non-image element. - Wrapper when image HTML is uncontrolled or generated outside React component ownership. 4. Ask the user concise follow-up questions until the implementation scope is concrete. Useful questions include: which pages or components are in scope, whether images should behave as a gallery, how captions should be sourced, whether SSR/RSC is involved, desired backdrop/theme behavior, mobile gesture expectations, and what the user wants to verify first. 5. Implement one pilot change on a representative image path. Keep the diff small and tell the user exactly what to test visually. 6. Apply user feedback to the pilot. Only after the user accepts the pattern, implement the remaining agreed scope. ### Auto decision rules - Prefer omitted `preset` or `preset="auto"` unless project evidence requires fixed desktop or mobile behavior. - Prefer Component mode for normal React-owned `` replacement. - Prefer Wrapper mode for CMS/MDX/markdown/rich-text surfaces where child `` nodes already exist. - Prefer Imperative mode for command menus, custom thumbnails, buttons, or callback-driven media opening. - In SSR, RSC, or code that may run during server render, import from `react-zmage/ssr` and call `Zmage.browsing()` only from a client event/effect. - Import `react-zmage/style.css` exactly once from the app-level style entry that the project already uses. - For non-light UIs, set the top-level `backdrop` to match the host page background; the default `backdrop` is white. - Start with default controller, hotKey, animate, and gesture behavior unless the user's project or feedback requires custom tuning. ### Minimal integration principles When integrating react-zmage into an existing site, keep the integration minimal. - For existing image lists or rich content, use `Zmage.Wrapper` with `set` so clicked images open at the matching gallery index. - You may set the top-level `backdrop` to match the host page background color. - Do not set optional behavior, `controller`, `controller.layout`, `edge`, `zIndex`, `animate`, `gesture`, `hotKey`, `radius`, `loop`, `coverVisible`, `hideOnScroll`, or `hideOnDblClick` props unless the user explicitly asks. - Only configure `controller.color` or `controller.backdrop` when the user asks for controller styling or when icons are visibly illegible after testing. Do not preemptively customize controller layout or placement. ### Expected deliverables When you finish the pilot or rollout, report: 1. Chosen usage mode and why it matches the project. 2. Install command used or needed. 3. Files changed. 4. Where `react-zmage/style.css` is imported. 5. Any SSR/RSC guard or client-boundary note. 6. What the user should inspect visually before approving broader rollout. ## Quick start ```bash npm install react-zmage # or: pnpm add react-zmage / yarn add react-zmage ``` ```tsx import Zmage from 'react-zmage' import 'react-zmage/style.css' // Component mode (most common): replace any with // Multi-image gallery: arrow keys flip pages // Imperative: open the viewer from any event handler. Returns a destructor. // `opts` accepts the same fields as props (see API reference below). const close = Zmage.browsing({ src: '/photo.jpg', backdrop: '#111' }) // later: close() // Wrapper: auto-attach to every inside an HTML subtree
// Controlled mode: drive open/close from React state via the `browsing` PROP // (not to be confused with the static method Zmage.browsing()). Always pair // the prop with onBrowsing so external state stays in sync. const [open, setOpen] = useState(false) // SSR / RSC: import from the SSR-safe subpath (no browser globals at module load) // import Zmage from 'react-zmage/ssr' ``` ## Choosing a usage mode - **Component**: you control the markup. Highest specificity, recommended default. - **Imperative**: open from arbitrary callbacks (e.g., a button onClick handler outside JSX). `Zmage.browsing(opts)` returns a destructor — calling it closes the viewer. Guard with `typeof window !== 'undefined'` if it can run server-side. - **Wrapper**: you don't control the rendered HTML (CMS markup, MDX, `dangerouslySetInnerHTML`). Wrapper scans its `` descendants on mount and on every re-render, then binds click handlers; new imgs injected without a Wrapper re-render are not picked up. Child `` nodes provide `src` / `alt`; viewer configuration props stay on ``. ## API quick reference `` props (and the same options bag accepts these for `Zmage.browsing(opts)`): | Prop | Type | Default | Notes | |------|------|---------|-------| | `src` | `string` | — | Required. Same as ``. | | `alt` | `string` | `''` | Same as ``. | | `caption` | `string \| { text: string; style?: CSSProperties; className?: string }` | `''` | Caption rendered below the image inside the viewer. Pass a string for the default pill, or an object to override styling (`style` is shallow-merged onto the default class; `className` appends). Per-set entries may override via `set[i].caption`. | | `set` | `Array<{ src, alt?, caption? }>` | `[]` | Multi-image gallery. When non-empty, arrow keys page. `caption` per item accepts the same `string \| object` shape as the top-level prop. In Wrapper mode, pass `set` for a shared gallery; a clicked child image whose `src` matches `set[i].src` opens that index. | | `defaultPage` | `number` | `0` | Initial index when `set` is non-empty. In Wrapper mode this is a fallback only; matched child image src wins. | | `backdrop` | `string` (CSS color) | `'#FFFFFF'` | **Default is white** — override (e.g. `'#111'`) for dark UIs or you'll get a white-on-white modal. | | `preset` | `'desktop' \| 'mobile' \| 'auto'` | `'auto'` | Picks default `controller` / `hotKey` / `animate` / `gesture` bundle plus preset-aware viewer spacing. Omitting `preset` uses `'auto'`. `'auto'` resolves at runtime via `matchMedia('(pointer: coarse) and (hover: none)')` — coarse + no-hover → mobile defaults, otherwise desktop. SSR / no `matchMedia` falls back to desktop. Use `preset="desktop"` to keep desktop behavior on touch devices. | | `controller` | `boolean \| ControllerSet` | preset | Per-button toggles: `pagination`, `rotate`, `rotateLeft`, `rotateRight`, `zoom`, `download`, `close`, `flip`, `flipLeft`, `flipRight`. `rotate` and `flip` are umbrellas: enabling either overrides its per-side counterparts (e.g. `rotate: true` shows both rotateLeft+rotateRight regardless of those flags). Visual keys: `backdrop` (toolbar bg, falls back to top-level `backdrop`) and `color` (icon color, falls back to `currentColor`). `controller.placement` is a `ControllerPlacement` and defaults to `top-right`; accepted values include `top-left`, `bottom-right`, `bottom-left`, `top-center`, `bottom-center`, `left-center`, and `right-center`. Placement moves only the toolbar capsule; side flip buttons and pagination keep their normal positions. `controller.layout` is a `ControllerOverlayLayout` for toolbar / flip / pagination / caption overlay safe insets; number values are px, strings are CSS lengths, scalar `inset` follows each target's natural edge (toolbar by placement, flip left/right, pagination/caption bottom), and `layout.mobile` overrides the base layout for mobile preset. It does not change image open/close geometry. Desktop defaults include `controller.layout.pagination.inset=24` and `controller.layout.caption.inset=60`; mobile leaves `layout` unset unless provided. `controller.render` is a `ControllerRender` callback for fully custom controller UI with signature `(args: { state: ControllerRenderState; actions: ControllerRenderActions; slots: ControllerRenderSlots }) => ReactNode`; return `null` to hide the controller layer. `state` includes `show`, `zoom`, `page`, `total`, `canZoom`, `canPrev`, `canNext`, `canDownload`, `preset`, `placement`, and `current`. `actions` include `close`, `zoom`, `rotateLeft`, `rotateRight`, `prev`, `next`, `toPage`, and `download`. `slots` expose built-in `Toolbar`, `Pagination`, `FlipLeft`, and `FlipRight` nodes for reuse. `controller=false` disables built-in slots and render. Configure `controller.backdrop` / `controller.color` only when the user asks for controller styling or visible testing shows illegible controls; e.g. `backdrop="#111"` + `controller={{ backdrop: 'rgba(0,0,0,0.4)', color: '#fff' }}`. Per-button string overrides (e.g. `controller.zoom: '#ff8800'`) still win over `controller.color`. | | `hotKey` | `boolean \| HotKey` (each entry: `boolean \| string \| string[]`) | preset | Keyboard shortcuts. Defaults: `close: 'Escape'`, `zoom: 'Space'`, `flipLeft / flipRight: 'ArrowLeft / ArrowRight'`, `rotateLeft / rotateRight: 'BracketLeft / BracketRight'` (`[` / `]`), `download: 'Mod+S'`. Each entry accepts `boolean` (use default), `string` (custom descriptor like `'Escape'` / `'Mod+S'` / `'KeyA'`), or `string[]` (multiple bindings). Descriptors use `e.code` names (layout-independent — `'BracketLeft'` is always physical `[`); single-letter / digit shortforms `'S'` / `'1'` are normalized to `'KeyS'` / `'Digit1'`. Modifier prefixes: `Mod` (= ⌘ on macOS, Ctrl elsewhere), `Cmd` / `Meta`, `Ctrl` / `Control`, `Shift`, `Alt` / `Option`. Strict modifier matching: `'Space'` is never matched by `Cmd+Space` (input-method switch) — undeclared modifiers must NOT be pressed. `flip` and `rotate` are umbrellas over their per-side counterparts; per-side string descriptor wins over umbrella. `download` defaults off (turning it on hijacks `Cmd/Ctrl+S`, suppressing the browser's "Save Page As" dialog). | | `animate` | `boolean \| { browsing?: boolean; flip?: 'fade' \| 'crossFade' \| 'swipe' \| 'zoom' \| 'blur' \| 'none'; cover?: boolean \| AnimateCoverOptions; slowMotion?: boolean }` | preset | Viewer open/close, cover-geometry, page-flip, and optional slow-motion transitions. `animate=false` disables animations; `animate.browsing=false` makes background, image, controls, and caption open/close without transition. `animate.cover` defaults to `{ objectFit: true, clip: true, radius: true }` and matches cover `object-fit` / `object-position`, clip-path crop, and border radius during open/close. It reads the clicked `` itself; parent-wrapper clipping such as `overflow:hidden`, parent radius, masks, complex `clip-path`, or transforms is not inferred. `clip-path` / `border-radius` animation may repaint and can be heavier than pure `transform` / `opacity`; use `animate={{ cover: { clip: false } }}` or `{ radius: false }` on performance-sensitive mobile pages. `animate.cover=false` keeps the legacy cover geometry path. `animate.flip='blur'` uses an optional soft-focus crossfade; `animate.flip='none'` skips adjacent-page rendering — page change is an instant swap with no transition (caption text also updates instantly). `animate.slowMotion=false` by default; set `animate={{ slowMotion: true }}` to let held `Shift` slow open/close browsing transitions to 10x for inspection and demos. | | `gesture` | `boolean \| GestureSet` | preset | Touch and wheel gestures. Desktop default is `{ swipe: false, dragExit: false, wheelZoom: { step: 0.12, smooth: true, minScale: 'fit', maxScale: 4, center: 'pointer', reverse: false, exitGuardDuration: 1000 }, pinchZoom: false, doubleTapZoom: false, touchAction: 'managed' }`; mobile default enables `swipe`, `dragExit`, `pinchZoom: { minScale: 'fit', maxScale: 4, resetBelowFit: true, center: 'gesture' }`, `doubleTapZoom: { scale: 1, minScale: 'fit', maxScale: 4, center: 'tap', interval: 300, distance: 32 }`, and `touchAction: 'managed'`, while disabling `wheelZoom`. `gesture=false` disables all gestures. `gesture.swipe` controls horizontal drag paging (`threshold`, `velocity`, `axisLock`, `resistance`); `gesture.dragExit` controls vertical drag-to-exit (`threshold`, `velocity`, `axisLock`, `opacity`); `gesture.wheelZoom` controls wheel/trackpad zoom while already zoomed (`step`, `smooth`, `minScale`, `maxScale`, `center`, `reverse`, `exitGuardDuration`); `gesture.pinchZoom` controls two-finger zoom (`minScale`, `maxScale`, `resetBelowFit`, `center`); `gesture.doubleTapZoom` controls one-finger double-tap zoom (`scale`, `minScale`, `maxScale`, `center`, `interval`, `distance`); `gesture.touchAction` is `GestureTouchAction = 'managed' \| 'auto' \| 'manipulation' \| 'none'`. Managed mode writes CSS `touch-action`: pinch uses `none`, double-tap-only uses `manipulation`, otherwise `auto`, which avoids browser double-tap zoom without non-passive `touchend`. `reverse` flips wheel direction. Zooming out to `minScale` exits zoom immediately; `exitGuardDuration` defaults to `1000` ms and blocks residual wheel events after that wheel-triggered zoom exit. Wheel zoom does not capture wheel events in browsing mode except during this short guard window, so normal scroll / `hideOnScroll` behavior remains intact. Single-image viewers ignore horizontal swipe, and zoom mode disables Phase 1 single-finger gestures. | | `hideOnScroll` | `boolean` | `true` | Auto-close on page scroll (desktop only). | | `hideOnDblClick` | `boolean` | `false` | Auto-close on image double-click. Off by default to preserve current single-click semantics; turn on to dismiss with a double-click. | | `coverVisible` | `boolean` | `false` | Keep the underlying `` visible while the modal is open. | | `zIndex` / `radius` / `edge` | `number` | `1000` / desktop `8`, mobile `0` / desktop `16`, mobile `0` | Modal stacking, image corner radius, viewport padding. | | `loop` | `boolean` | `true` | Wrap-around when paging past the ends. | | `loadingDelay` | `number` | `200` | Delay (ms) before showing the loading indicator. If the image loads within this window, the indicator never appears — prevents the flash on cached page changes. Set 0 for legacy instant-show. | | `onBrowsing` | `(isBrowsing: boolean) => void` | — | Fires on open/close. | | `onZooming` | `(isZooming: boolean) => void` | — | Fires on zoom in/out. | | `onSwitching` | `(page: number) => void` | — | Fires on page change. | | `onRotating` | `(deg: number) => void` | — | Fires on rotation. | | `onError` | `(e: SyntheticEvent) => void` | — | Fires when an image (cover **or** viewer) fails to load. The cover-image case also still flows through native `` `onError` via HTML attribute passthrough; this prop is the only way to observe the **viewer** image's load failure. | | `browsing` | `boolean` | — | **Controlled-mode prop**, distinct from the static method `Zmage.browsing()`. Pair with `onBrowsing` to drive open state from outside. Does not control ``. | Standard `` HTML attributes (`className`, `style`, `width`, `height`, `loading`, `id`, etc.) pass through to the cover image. Controller render example: ```tsx { if (!state.show) return null return (
{state.page + 1} / {state.total} {state.canDownload && } {slots.Pagination}
) }, }} /> ``` Wrapper mode prop scope: - Put `src` / `alt` on child `` nodes; top-level `src` / `alt` are overridden by the clicked DOM node. - Pass viewer behavior and visuals on ``: `preset`, `controller`, `hotKey`, `animate`, `gesture`, `backdrop`, `zIndex`, `radius`, `edge`, `loop`, `coverVisible`, `hideOnScroll`, `hideOnDblClick`, `loadingDelay`, plus lifecycle callbacks. - Pass `set` when the wrapped subtree should be one shared gallery. If clicked `img.src` matches `set[i].src`, Wrapper opens `i`; otherwise it falls back to `defaultPage`. - Without `set`, the clicked image opens alone. `data-zmage-caption` or the nearest `figcaption` can provide the viewer caption. - `browsing` is component-controlled state and does not control Wrapper. ### Custom hotkey bindings ```tsx // Enable Cmd/Ctrl+S to download the current image (off by default — opt in) // Rebind rotate to A / D, keep download default // Add Q as a second close key alongside the default Escape // Custom download shortcut (Mod = ⌘ on macOS, Ctrl on Windows/Linux) ``` Descriptor cheatsheet (uses `e.code` names — layout-independent): - letters / digits short forms: `'S'` → `KeyS`, `'1'` → `Digit1` - arrows: `'ArrowLeft'` / `'ArrowRight'` / `'ArrowUp'` / `'ArrowDown'` - punctuation: `'BracketLeft'` (`[`) / `'BracketRight'` (`]`) / `'Comma'` / `'Period'` / `'Slash'` - whitespace: `'Space'` / `'Enter'` / `'Tab'` / `'Backspace'` - modifiers (prefix): `'Mod+'` (= ⌘ or Ctrl) / `'Cmd+'` / `'Ctrl+'` / `'Shift+'` / `'Alt+'` ## Theming The library is theme-agnostic by design — there's no theme provider, no light/dark switch. Read your resolved theme value at the host level and pass the top-level `backdrop` accordingly; icons default to SVG `currentColor` (override globally with `controller.color`, or per-button by passing a color string to `controller.zoom` etc.). Only set `controller.backdrop` / `controller.color` when the user asks for controller styling or visible testing shows the controls are illegible on the chosen `backdrop`. Common host-side reset gotchas (Tailwind preflight, normalize.css, Bootstrap) were defended against in lib v1.1.2 — upgrade if `` opens with the modal image rendering smaller than the cover. ## Docs - [README.md](https://github.com/Caldis/react-zmage/blob/master/README.md): full human-facing documentation, install, all props, examples - [AGENTS.md](https://github.com/Caldis/react-zmage/blob/master/AGENTS.md): condensed agent-oriented reference, public API contract, common pitfalls, architectural invariants - [Online docs](https://zmage.caldis.me/docs): API reference, three-mode walkthrough, theming guide, FAQ ## Source of truth - [packages/core/src/types/global.ts](https://github.com/Caldis/react-zmage/blob/master/packages/core/src/types/global.ts): canonical TypeScript definitions for all props (`BaseType`, `Set`, `Preset`, `ControllerSet`, `ControllerPlacement`, `ControllerOverlayLayout`, `ControllerLayoutTargets`, `ControllerLayoutTarget`, `ControllerLayoutInset`, `ControllerLayoutInsetValue`, `ControllerRender`, `ControllerRenderState`, `ControllerRenderActions`, `ControllerRenderSlots`, `HotKey`, `Animate`, `AnimateFlip`, `AnimateCoverOptions`, `GestureSet`, `GestureSwipeOptions`, `GestureDragExitOptions`, `GestureWheelZoomOptions`, `GesturePinchZoomOptions`, `GestureDoubleTapZoomOptions`, `GestureTouchAction`, lifecycle params) - [packages/core/src/types/default.ts](https://github.com/Caldis/react-zmage/blob/master/packages/core/src/types/default.ts): canonical defaults (`defProp`, `defPreset` for desktop/mobile) - [packages/core/src/index.ts](https://github.com/Caldis/react-zmage/blob/master/packages/core/src/index.ts): runtime exports - [packages/core/package.json](https://github.com/Caldis/react-zmage/blob/master/packages/core/package.json): publish contract (`exports` field, peer deps) ## Examples - [packages/home/src/App.tsx](https://github.com/Caldis/react-zmage/blob/master/packages/home/src/App.tsx): live demo site source - [packages/sandbox-r17/src/consumer.tsx](https://github.com/Caldis/react-zmage/blob/master/packages/sandbox-r17/src/consumer.tsx): minimal R17 consumer (also r18, r19) - [Live playground](https://zmage.caldis.me/playground): real-time prop debugging ## Common pitfalls 1. Forgetting `import 'react-zmage/style.css'` — viewer renders unstyled. 2. Confusing the static method `Zmage.browsing(opts)` (imperative open, returns a destructor) with the `browsing` prop (controlled-mode boolean). Same name, different things — the prop must always be paired with an `onBrowsing` handler or state desyncs silently. 3. Wrapper not picking up dynamically-injected imgs — Wrapper only re-scans on its own re-render, not on DOM mutations. 4. Putting `src` / `alt` on `` and expecting them to render images — Wrapper reads actual descendant `` nodes instead. 5. Calling `Zmage.browsing()` in code that may run server-side — guard with `typeof window !== 'undefined'`, or import from `react-zmage/ssr`. 6. Leaving `backdrop` at the default `#FFFFFF` on a dark-themed page — the modal may clash with the host page. Set the top-level `backdrop` explicitly if your host UI isn't light. 7. Preemptively setting `controller`, `controller.layout`, `edge`, `zIndex`, `animate`, `gesture`, `hotKey`, `radius`, `loop`, `coverVisible`, `hideOnScroll`, or `hideOnDblClick` during a basic install — keep these defaults unless the user asks or testing exposes a concrete issue. ## Optional - [Online demo](https://zmage.caldis.me) - [GitHub repo](https://github.com/Caldis/react-zmage) - [npm package](https://www.npmjs.com/package/react-zmage)