npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

react-zmage

v1.8.4

Published

Origin-expand fullscreen React image viewer for img elements, galleries, rich text, and SSR/RSC apps

Readme

English | 简体中文


Highlights

  • Origin-expand <img> replacement. Native props (className, style, onClick, …) pass through to the underlying image. Existing images open into a fullscreen viewer from their original position.
  • SSR / RSC safe. A separate react-zmage/ssr entry avoids touching document at import time. Verified against Next.js 15 App Router, Vite SSR, and Express renderToString.
  • Three call modes. Use it as a component, call it imperatively (Zmage.browsing()), or wrap any HTML subtree to auto-attach the viewer to every <img> inside.

Install

npm install react-zmage    # or: pnpm add react-zmage / yarn add react-zmage
import Zmage from 'react-zmage'
import 'react-zmage/style.css'

<Zmage src="/photo.jpg" alt="hero" />

Peer deps: react@>=16.8 <20 and react-dom@>=16.8 <20. The library auto-detects React 18+ at runtime and uses react-dom/client when available — consumers configure nothing.

AI agents should read https://zmage.caldis.me/llms.txt first, then keep basic integrations minimal.


Three ways to use it

react-zmage exposes the same configuration surface through three call shapes. Pick based on how much control you have over the rendered HTML.

Component — the default

When to use: you control the JSX you render. This is the cleanest path; reach for it first.

import Zmage from 'react-zmage'
import 'react-zmage/style.css'

export default function Gallery() {
  return <Zmage src="/photo.jpg" alt="landscape" />
}

All native HTML attributes (className, style, onClick, loading, …) pass through to the underlying <img>.

Imperative — Zmage.browsing()

When to use: you have no good cover <img>, or you don't want to mount extra nodes in your component tree. Open the viewer from event handlers, async callbacks, or third-party widgets — anywhere.

import Zmage from 'react-zmage'

function Trigger() {
  return (
    <button onClick={() => Zmage.browsing({ src: '/photo.jpg' })}>
      Open viewer
    </button>
  )
}

Zmage.browsing(opts) accepts the same props bag as <Zmage> and returns a () => void destructor for manual close.

Guard with typeof window !== 'undefined' if it can run on the server. The react-zmage/ssr entry provides the same API without touching document at import time.

Wrapper — <Zmage.Wrapper>

When to use: you don't control the rendered HTML — markdown output, CMS rich text, dangerouslySetInnerHTML. Wrap the subtree and every <img> inside automatically gains the viewer, without modifying the source content.

<Zmage.Wrapper backdrop="#0a0a0a">
  <article dangerouslySetInnerHTML={{ __html: htmlContent }} />
</Zmage.Wrapper>

The wrapper queries <img> descendants in componentDidMount / componentDidUpdate. Imgs injected after the wrapper renders won't get bound until the wrapper re-renders.

Wrapper-specific prop scope:

  • Put src / alt on the child <img> nodes. Top-level src / alt are overwritten by the clicked DOM node.
  • Viewer configuration still belongs on <Zmage.Wrapper>: preset, controller, hotKey, animate, gesture, backdrop, zIndex, radius, edge, loop, coverVisible, hideOnScroll, hideOnDblClick, loadingDelay, and lifecycle callbacks.
  • Pass set when the wrapped subtree should behave as one shared gallery. If the clicked image's src appears in set, Wrapper opens that matching index; defaultPage is only the fallback.
  • Without set, the clicked image opens as a single image. data-zmage-caption or the nearest figcaption can provide the viewer caption.
  • The controlled browsing prop is for component mode; it does not control <Zmage.Wrapper>.
import Zmage from 'react-zmage'
import type { BaseType } from 'react-zmage'
import { useRef } from 'react'

const config: BaseType = {
  src: '/photo.jpg',
  alt: 'hero',
  onBrowsing: (state) => console.log('browsing:', state),
}

const ref = useRef<HTMLImageElement>(null)
return <Zmage {...config} ref={ref} />

BaseType is the union of every prop. Sub-types — ControllerSet, ControllerPlacement, ControllerOverlayLayout, ControllerLayoutTargets, ControllerLayoutTarget, ControllerLayoutInset, ControllerLayoutInsetValue, ControllerRender, ControllerRenderState, ControllerRenderActions, ControllerRenderSlots, HotKey, Animate, AnimateCoverOptions, GestureSet, GestureSwipeOptions, GestureDragExitOptions, GestureWheelZoomOptions, GesturePinchZoomOptions, GestureDoubleTapZoomOptions, GestureTouchAction, Set, Preset, AnimateFlip — are also exported from react-zmage.

import Zmage from 'react-zmage/ssr'
import 'react-zmage/style.css'

API is identical — only the import path changes. The SSR build is platform-neutral and avoids browser APIs at module load. Verified against Next.js 15 App Router (packages/sandbox-nextjs) and Express + Vite renderToString (apps/demo-ssr).


API reference

All props live on a single BaseType. The same options bag works for <Zmage> and Zmage.browsing().

Data

| Prop | Type | Default | Notes | |---|---|---|---| | src | string | — | Image URL. Same as <img src>. | | alt | string | '' | Image title; rendered above the viewer in browsing mode. | | caption | string \| { text: string; style?: CSSProperties; className?: string } | '' | Caption rendered below the viewer. String form uses the default pill style; object form lets you override styling or theme it. Per-page override available via set[i].caption. | | set | Set[] | [] | Multi-image gallery. When non-empty, arrow keys flip pages. In Wrapper mode, pass set to treat wrapped images as one shared gallery; clicking an image whose src appears in set opens that matching index. | | defaultPage | number | 0 | Initial index when set is non-empty. In Wrapper mode this is a fallback only; a clicked image that matches set[i].src wins. |

Preset

| Prop | Type | Default | Notes | |---|---|---|---| | preset | 'desktop' \| 'mobile' \| 'auto' | 'auto' | Bundles defaults for controller, hotKey, animate, gesture, and preset-aware viewer spacing. Omitting preset uses 'auto'. 'auto' resolves at runtime via matchMedia('(pointer: coarse) and (hover: none)') — coarse + no-hover → mobile, otherwise desktop. SSR / no matchMedia falls back to desktop. Use preset="desktop" to keep desktop behavior on touch devices. |

Functional

| Prop | Type | Default | Notes | |---|---|---|---| | controller | boolean \| ControllerSet | preset-driven | Toolbar controls. Pass false to hide all controls, or a partial object to override buttons, toolbar placement, overlay layout, or the full render function. | | hotKey | boolean \| HotKey | preset-driven | Keyboard shortcuts. | | animate | boolean \| Animate | preset-driven | Open/close, cover-geometry, and page-flip animations. | | gesture | boolean \| GestureSet | preset-driven | Touch and wheel gestures. Pass false to disable all gestures, or a partial object to override swipe / dragExit / wheelZoom / pinchZoom / doubleTapZoom / touchAction. |

ControllerSet

interface ControllerSet {
  pagination?:  boolean | ReactNode             // page indicator
  zoom?:        boolean | string | ReactNode    // zoom button
  download?:    boolean | string | ReactNode
  close?:       boolean | string | ReactNode
  rotate?:      boolean | string | ReactNode    // umbrella over rotateLeft + rotateRight
  rotateLeft?:  boolean | string | ReactNode
  rotateRight?: boolean | string | ReactNode
  flip?:        boolean | string | ReactNode    // umbrella over flipLeft + flipRight
  flipLeft?:    boolean | string | ReactNode
  flipRight?:   boolean | string | ReactNode
  // visual
  backdrop?:    string                          // control bar bg; falls back to top-level `backdrop`
  color?:       string                          // control bar icon color; falls back to `currentColor`
  placement?:   ControllerPlacement             // default 'top-right'
  layout?:      ControllerOverlayLayout         // toolbar / flip / pagination / caption overlay safe insets
  render?:      ControllerRender                // replace the whole controller UI
}

type ControllerPlacement =
  | 'top-right'
  | 'top-left'
  | 'bottom-right'
  | 'bottom-left'
  | 'top-center'
  | 'bottom-center'
  | 'left-center'
  | 'right-center'

type ControllerLayoutInsetValue = number | string
type ControllerLayoutInset =
  | ControllerLayoutInsetValue
  | {
      top?: ControllerLayoutInsetValue
      right?: ControllerLayoutInsetValue
      bottom?: ControllerLayoutInsetValue
      left?: ControllerLayoutInsetValue
    }

interface ControllerLayoutTarget {
  inset?: ControllerLayoutInset
}

interface ControllerLayoutTargets {
  toolbar?: ControllerLayoutTarget
  flip?: ControllerLayoutTarget
  pagination?: ControllerLayoutTarget
  caption?: ControllerLayoutTarget
}

interface ControllerOverlayLayout extends ControllerLayoutTargets {
  mobile?: ControllerLayoutTargets
}

type ControllerRender = (args: {
  state: ControllerRenderState
  actions: ControllerRenderActions
  slots: ControllerRenderSlots
}) => ReactNode

interface ControllerRenderState {
  show: boolean
  zoom: boolean
  page: number
  total: number
  canZoom: boolean
  canPrev: boolean
  canNext: boolean
  canDownload: boolean
  preset: 'desktop' | 'mobile'
  placement: ControllerPlacement
  current?: Set
}

interface ControllerRenderActions {
  close: () => void
  zoom: () => void
  rotateLeft: () => void
  rotateRight: () => void
  prev: () => void
  next: () => void
  toPage: (page: number) => void
  download: () => void
}

interface ControllerRenderSlots {
  Toolbar: ReactNode
  Pagination: ReactNode
  FlipLeft: ReactNode
  FlipRight: ReactNode
}

rotate and flip are umbrella switches — enabling either forces both per-side counterparts on, regardless of those flags.

backdrop and color decouple the toolbar from the modal backdrop. Pair them when the modal backdrop is dark — e.g. backdrop="#111" + controller={{ backdrop: 'rgba(0,0,0,0.4)', color: '#fff' }} keeps the toolbar legible. Per-button color overrides (e.g. controller={{ zoom: '#ff8800' }}) still win over controller.color.

placement moves only the toolbar capsule. Side flip buttons and pagination keep their existing positions. layout adjusts overlay safe insets for the toolbar, side flip buttons, pagination, and caption without changing the image animation geometry. A number is treated as px, a string is passed through as a CSS length, and a scalar inset follows each target's natural entry edge: toolbar uses the current placement, side flips use left / right, and pagination / caption use bottom. The desktop preset sets pagination.inset=24 and caption.inset=60; the mobile preset leaves layout unset unless you pass it. layout.mobile is merged on top when the resolved preset is mobile. render receives { state, actions, slots } and replaces the whole controller layer; slots.Toolbar, slots.Pagination, slots.FlipLeft, and slots.FlipRight let custom UI reuse the built-in pieces. controller={false} disables both built-in slots and render.

<Zmage
  src="photo.jpg"
  caption="Long caption"
  set={[
    { src: 'photo.jpg', caption: 'Long caption' },
    { src: 'detail.jpg', caption: 'Detail' },
  ]}
  controller={{
    placement: 'bottom-center',
    layout: {
      toolbar: { inset: '1rem' },
      flip: { inset: '1rem' },
      pagination: { inset: '1.5rem' },
      caption: { inset: '4rem' },
      mobile: {
        pagination: { inset: '2.75rem' },
        caption: { inset: '5.25rem' },
      },
    },
  }}
/>

render returns any React node. Return null to hide the controller layer, call actions to drive the viewer, and read state to keep custom UI in sync with page, zoom, placement, and capability flags:

| Path | Type | |---|---| | state | ControllerRenderState | | state.show | boolean | | state.zoom | boolean | | state.page | number | | state.total | number | | state.canZoom | boolean | | state.canPrev | boolean | | state.canNext | boolean | | state.canDownload | boolean | | state.preset | 'desktop' \| 'mobile' | | state.placement | ControllerPlacement | | state.current | Set \| undefined | | actions | ControllerRenderActions | | actions.close | () => void | | actions.zoom | () => void | | actions.rotateLeft | () => void | | actions.rotateRight | () => void | | actions.prev | () => void | | actions.next | () => void | | actions.toPage | (page: number) => void | | actions.download | () => void | | slots | ControllerRenderSlots | | slots.Toolbar | ReactNode | | slots.Pagination | ReactNode | | slots.FlipLeft | ReactNode | | slots.FlipRight | ReactNode | | return | ReactNode |

<Zmage
  src="photo.jpg"
  set={[
    { src: 'photo.jpg', alt: 'Cover' },
    { src: 'detail.jpg', alt: 'Detail' },
  ]}
  controller={{
    placement: 'bottom-center',
    render: ({ state, actions, slots }) => {
      if (!state.show) return null

      return (
        <div className="my-zmage-controls" data-placement={state.placement}>
          <button type="button" disabled={!state.canPrev} onClick={actions.prev}>
            Prev
          </button>
          <span>
            {state.page + 1} / {state.total}
          </span>
          <button type="button" disabled={!state.canNext} onClick={actions.next}>
            Next
          </button>
          <button type="button" disabled={!state.zoom && !state.canZoom} onClick={actions.zoom}>
            {state.zoom ? 'Fit' : 'Zoom'}
          </button>
          {state.canDownload && (
            <button type="button" onClick={actions.download}>
              Download
            </button>
          )}
          <button type="button" onClick={actions.close}>
            Close
          </button>

          {/* Reuse built-in pieces only where you want them. */}
          {slots.Pagination}
        </div>
      )
    },
  }}
/>

Preset defaults

| Field | desktop | mobile | |---|---|---| | pagination | ✅ | ✅ | | rotate | ✅ | — | | zoom | ✅ | — | | download | — | — | | close | ✅ | ✅ | | flip | ✅ | — | | placement | top-right | top-right | | radius | 8 | 0 | | edge | 16 | 0 | | controller.layout.pagination.inset | 24 | — | | controller.layout.caption.inset | 60 | — | | gesture.swipe | — | ✅ | | gesture.dragExit | — | ✅ | | gesture.wheelZoom | ✅ | — | | gesture.pinchZoom | — | ✅ | | gesture.doubleTapZoom | — | ✅ | | gesture.touchAction | managed | managed |

HotKey

type HotKeyValue = boolean | string | string[]
//  true     — use default binding
//  false    — disabled, event passes to outer listeners
//  string   — descriptor: 'Escape' / 'BracketLeft' / 'S' / 'Mod+S'
//             (e.code names — layout-independent;
//              Mod = ⌘ on macOS, Ctrl on Windows/Linux)
//  string[] — multiple bindings, any matches triggers

interface HotKey {
  close?:        HotKeyValue   // default 'Escape'
  zoom?:         HotKeyValue   // default 'Space'
  flip?:         boolean       // umbrella for flipLeft / flipRight
  flipLeft?:     HotKeyValue   // default 'ArrowLeft'
  flipRight?:    HotKeyValue   // default 'ArrowRight'
  rotate?:       boolean       // umbrella for rotateLeft / rotateRight
  rotateLeft?:   HotKeyValue   // default 'BracketLeft'  ([)
  rotateRight?:  HotKeyValue   // default 'BracketRight' (])
  download?:     HotKeyValue   // default 'Mod+S' (when enabled)
}

Desktop default: close / zoom / flip / rotate on; download off (opt-in — turning it on hijacks the browser's Cmd/Ctrl+S shortcut). Mobile default: all off.

Strict modifier matching: 'Space' is never matched by Cmd+Space (macOS input-method switch); undeclared modifiers must NOT be pressed. Per-side string descriptor wins over the umbrella (e.g. { rotate: true, rotateLeft: 'KeyA' } rebinds left to A while keeping ] for right).

Examples:

// Enable Cmd/Ctrl+S to download the current image
<Zmage src="..." hotKey={{ download: true }} />

// Rebind rotate to A / D, keep download default
<Zmage src="..." hotKey={{ rotate: false, rotateLeft: 'KeyA', rotateRight: 'KeyD' }} />

// Add Q as a second close key alongside Escape
<Zmage src="..." hotKey={{ close: ['Escape', 'KeyQ'] }} />

Animate

interface Animate {
  browsing?: boolean
  flip?:     'fade' | 'crossFade' | 'swipe' | 'zoom' | 'blur' | 'none'
  cover?:    boolean | AnimateCoverOptions
  slowMotion?: boolean
}

interface AnimateCoverOptions {
  objectFit?: boolean  // default true
  clip?: boolean       // default true
  radius?: boolean     // default true
}

Defaults: desktop = { browsing: true, flip: 'crossFade', cover: { objectFit: true, clip: true, radius: true }, slowMotion: false }, mobile = { browsing: true, flip: 'swipe', cover: { objectFit: true, clip: true, radius: true }, slowMotion: false }. animate.cover matches the cover image's object-fit / object-position, clip, and border radius during open / close. Set animate={{ cover: false }} for the legacy cover geometry path. flip: 'blur' uses a soft-focus crossfade for optional page changes, while flip: 'none' skips adjacent-page rendering — page change is an instant swap with no transition. animate.slowMotion is off by default; when set to true, holding Shift while opening or closing slows the full browsing transition to 10x for inspection and demos.

animate.cover reads the clicked <img> itself. It can match object-fit, object-position, and border-radius applied directly to that image; clipping introduced by a parent wrapper (overflow: hidden, parent radius, mask, complex clip-path, transform, etc.) is not inferred. The geometry math is small, but animating clip-path: inset(...) and border-radius may repaint and is heavier than pure transform / opacity, especially on large images, weaker mobile devices, and iOS Safari. For performance-sensitive pages, use animate={{ cover: { clip: false } }} or animate={{ cover: { radius: false } }}.

GestureSet

interface GestureSet {
  swipe?: boolean | GestureSwipeOptions
  dragExit?: boolean | GestureDragExitOptions
  wheelZoom?: boolean | GestureWheelZoomOptions
  pinchZoom?: boolean | GesturePinchZoomOptions
  doubleTapZoom?: boolean | GestureDoubleTapZoomOptions
  touchAction?: GestureTouchAction
}

type GestureTouchAction = 'managed' | 'auto' | 'manipulation' | 'none'

interface GestureSwipeOptions {
  threshold?: number    // default 120
  velocity?: number     // default 0.35 px/ms
  axisLock?: number     // default 1.2
  resistance?: number   // default 0.35 at non-loop edges
}

interface GestureDragExitOptions {
  threshold?: number    // default 80
  velocity?: number     // default 0.35 px/ms
  axisLock?: number     // default 1.2
  opacity?: boolean     // default true
}

interface GestureWheelZoomOptions {
  step?: number                  // default 0.12
  smooth?: boolean               // default true
  minScale?: 'fit' | number      // default 'fit'
  maxScale?: number              // default 4
  center?: 'pointer' | 'viewport' // default 'pointer'
  reverse?: boolean              // default false
  exitGuardDuration?: number     // default 1000ms; blocks residual wheel after exit
}

interface GesturePinchZoomOptions {
  minScale?: 'fit' | number      // default 'fit'
  maxScale?: number              // default 4
  resetBelowFit?: boolean        // default true
  center?: 'gesture' | 'viewport' // default 'gesture'
}

interface GestureDoubleTapZoomOptions {
  scale?: number                 // default 1
  minScale?: 'fit' | number      // default 'fit'
  maxScale?: number              // default 4
  center?: 'tap' | 'viewport'    // default 'tap'
  interval?: number              // default 300ms
  distance?: number              // default 32px
}

Desktop default: { 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 horizontal drag paging, vertical drag-to-exit, two-finger pinch zoom, and single-finger double-tap zoom with the option defaults above, disables wheelZoom, and keeps touchAction: 'managed'.

Wheel zoom is active only while the viewer is already in zoom mode; normal browsing wheel/scroll behavior stays untouched. Zooming out to minScale exits zoom immediately; exitGuardDuration then blocks residual wheel events for the configured time so trackpad momentum does not scroll/close the page in the same gesture. Pinch zoom uses the two-finger midpoint by default; shrinking back to the fit scale exits zoom and recenters the image. Double-tap zoom uses touch-action instead of a non-passive touchend listener to avoid fighting the browser's default double-tap zoom. touchAction: 'managed' resolves to none when pinch zoom is active, to manipulation for double-tap-only setups, and to auto otherwise; explicit auto / manipulation / none values are written as-is. gesture={{ swipe: false }} only disables drag paging; gesture={{ dragExit: false }} only disables drag-to-exit; gesture={{ wheelZoom: false }} only disables wheel zoom; gesture={{ pinchZoom: false }} only disables pinch zoom; gesture={{ doubleTapZoom: false }} only disables double-tap zoom. Single-image viewers ignore horizontal swipe, and zoom mode disables Phase 1 single-finger drag gestures.

Interface & interaction

| Prop | Type | Default | Notes | |---|---|---|---| | hideOnScroll | boolean | true | Auto-close when the page scrolls (desktop only). | | hideOnDblClick | boolean | false | Auto-close when the user double-clicks the image. Off by default; turn on to allow dismissing with a double-click. | | coverVisible | boolean | false | Keep the cover <img> visible while the modal is open. | | backdrop | string | '#FFFFFF' | Viewer backdrop. Any valid CSS color or gradient. Default is white — override ('#111', etc.) for dark UIs. | | zIndex | number | 1000 | Portal stacking. | | radius | number | desktop 8, mobile 0 | Image corner radius (px). | | edge | number | desktop 16, mobile 0 | Minimum margin between image and viewport (px). | | 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. |

Lifecycle

| Prop | Signature | Triggered when | |---|---|---| | onBrowsing | (isBrowsing: boolean) => void | viewer opens / closes | | onZooming | (isZooming: boolean) => void | 1:1 zoom toggles | | onSwitching | (page: number) => void | page changes | | onRotating | (deg: number) => void | image rotates | | onError | (e: SyntheticEvent<HTMLImageElement>) => void | cover or viewer image fails to load (the only hook for the viewer-side failure; cover still also flows via native <img> onError passthrough) |

Controlled

| Prop | Type | Default | Notes | |---|---|---|---| | browsing | boolean | (uncontrolled) | Controlled-mode prop, distinct from the static method Zmage.browsing(). Pair with onBrowsing so external state stays in sync. Omit for self-managed open/close. Does not control <Zmage.Wrapper>. |

Native passthrough

Every HTMLAttributes<HTMLImageElement> (className, style, width, height, loading, id, data-*, …) is forwarded to the cover <img>.

Full type

export type BaseType =
  & BaseParams                    // src / alt / caption / set / defaultPage
  & PresetParams                  // preset
  & FunctionalParams              // controller / hotKey / animate / gesture
  & InterfaceAndInteractionParams // hideOnScroll / hideOnDblClick / coverVisible / backdrop / zIndex / radius / edge / loop / loadingDelay
  & LifeCycleParams               // onBrowsing / onZooming / onSwitching / onRotating / onError
  & ControlledParams              // browsing
  & HTMLAttributes<HTMLImageElement>

Canonical sources of truth:


React compatibility

| React | Status | Mount API | |---|---|---| | 16.8 — 17.x | ✅ Supported | ReactDOM.render | | 18.x | ✅ Supported | createRoot (auto-detected) | | 19.x | ✅ Supported | createRoot (required, auto-adapted) |

Runtime feature detection picks the right mount API; consumers configure nothing. See resolveMountAdapter in Zmage.callee.tsx.


Recipes

Multi-image gallery

<Zmage
  src="/cover.jpg"
  set={[
    { src: '/01.jpg', alt: 'page 1', style: { borderRadius: 30 } },
    { src: '/02.jpg', alt: 'page 2' },
  ]}
/>

In component and imperative modes, when set is non-empty, the first image you see in browsing mode is set[defaultPage], not src. To keep the cover and the first viewer page in sync, put the cover in set[0] and pass it to src as well. In Wrapper mode, a clicked child image that matches set[i].src opens that index automatically.

Selectively disable controls

<Zmage
  src="/x.jpg"
  controller={{ download: true, rotate: false }}
/>

Controlled state

const [open, setOpen] = useState(false)

return (
  <>
    <button onClick={() => setOpen(true)}>View</button>
    <Zmage src="/x.jpg" browsing={open} onBrowsing={setOpen} />
  </>
)

Theme-aware backdrop

<Zmage src="/x.jpg" backdrop="linear-gradient(90deg, #00d4ff, #1a5ed7)" />

For more recipes, see the live Playground — every prop is controllable and the URL is shareable.


Contributing

PRs welcome — see AGENTS.md for an at-a-glance project map and the architectural invariants to respect.

This is a pnpm + turbo monorepo:

packages/
  core/                    # the published react-zmage package
  home/                    # CSR demo (Vite SPA, switchable React via env)
  sandbox-r{17,18,19}/     # real-npm-consumer integration tests
  sandbox-nextjs/          # Next.js 15 + RSC consumer build smoke
apps/
  demo-ssr/                # Express + Vite SSR demo (R19)
  demo-nextjs/             # Next.js 15 App Router demo

Common commands:

pnpm install
pnpm build               # build core + home
pnpm test                # vitest in jsdom
pnpm -w run check        # full cross-version: build → pack → reinstall → 4 sandboxes tsc + ssr-smoke

# Interactive demos for human verification
pnpm dev:csr-r17 / r18 / r19   # CSR · Vite SPA
pnpm dev:ssr-r19                # SSR · Express        (:8090)
pnpm dev:nextjs                 # RSC · Next.js        (:8095)

Each demo shows a top-bar ContextBanner with the actual loaded React version and render mode, so you can confirm context when switching environments.


License

MIT


Acknowledgements