Caldis/react-zmage
 Watch    11
 Star    937
 Fork    94

English | 简体中文

react-zmage logo

react-zmage

Turn any <img> into an origin-expand fullscreen React image viewer.
Drop-in. Zero config. React 16.8 → 19.

npm version npm downloads/month minzipped size React 16.8 — 19 MIT license

Live Demo · Demo Video · Playground · Use Cases · API · AGENTS.md


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, portalTarget, 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>.
TypeScript
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.

SSR / RSC (Next.js, Remix)
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.
portalTarget HTMLElement | null document.body Custom DOM element for mounting the viewer Portal. Use it when an app has a dedicated overlay root, modal root, shadow host, or micro-frontend container. It changes the mount parent only; the viewer still uses fullscreen fixed positioning.
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.

portalTarget is for host apps that already centralize overlays outside the normal content tree. It does not make a local, clipped preview; use zIndex and your app shell's stacking rules to control how the fullscreen viewer sits above other UI.

import { useState } from 'react'
import Zmage from 'react-zmage'
import 'react-zmage/style.css'

export function ArticleImage () {
  const [viewerRoot, setViewerRoot] = useState<HTMLElement | null>(null)

  return (
    <section className="article-shell">
      <div id="article-viewer-root" ref={setViewerRoot} />
      <Zmage src="/photo.jpg" alt="Article photo" portalTarget={viewerRoot} />
    </section>
  )
}

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 / portalTarget / 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

关于
Turn any <img> into an origin-expand fullscreen React image viewer.
最后更新于  8 days ago
License