react-zmage
v1.8.4
Published
Origin-expand fullscreen React image viewer for img elements, galleries, rich text, and SSR/RSC apps
Maintainers
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/ssrentry avoids touchingdocumentat 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-zmageimport 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. Thereact-zmage/ssrentry provides the same API without touchingdocumentat 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/alton the child<img>nodes. Top-levelsrc/altare 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
setwhen the wrapped subtree should behave as one shared gallery. If the clicked image'ssrcappears inset, Wrapper opens that matching index;defaultPageis only the fallback. - Without
set, the clicked image opens as a single image.data-zmage-captionor the nearestfigcaptioncan provide the viewer caption. - The controlled
browsingprop 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>andZmage.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
}
rotateandflipare umbrella switches — enabling either forces both per-side counterparts on, regardless of those flags.
backdropandcolordecouple the toolbar from the modal backdrop. Pair them when the modalbackdropis 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 overcontroller.color.
placementmoves only the toolbar capsule. Side flip buttons and pagination keep their existing positions.layoutadjusts 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 scalarinsetfollows each target's natural entry edge: toolbar uses the currentplacement, side flips use left / right, and pagination / caption use bottom. The desktop preset setspagination.inset=24andcaption.inset=60; the mobile preset leaveslayoutunset unless you pass it.layout.mobileis merged on top when the resolved preset is mobile.renderreceives{ state, actions, slots }and replaces the whole controller layer;slots.Toolbar,slots.Pagination,slots.FlipLeft, andslots.FlipRightlet custom UI reuse the built-in pieces.controller={false}disables both built-in slots andrender.
<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:
packages/core/src/types/global.ts— prop typespackages/core/src/types/default.ts— preset defaults
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 demoCommon 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
Acknowledgements
- Icons — Material Icons
- AI-friendly install instruction available at
zmage.caldis.me/llms.txt— paste the URL to your AI agent.
