Open-source project
Caldis/react-zmage avatar
Caldis/react-zmage

react-zmage turns an ordinary img tag into a fullscreen viewer

Turn any <img> into an origin-expand fullscreen React image viewer.

945 stars92 forksTypeScriptMIT

At a glance

What is it?
react-zmage is a TypeScript React package that replaces or intercepts plain image tags with an origin-expand fullscreen viewer, with a component form, an imperative form and a wrapper form. It suits teams that already render images from a CMS or markdown and need zoom without rewriting markup.
Who is it for?
Adopt react-zmage if your images already reach the page as markup you do not fully control, since the wrapper mode is the only route that binds a viewer to images you never authored. Skip it if you want a gallery that manages its own thumbnail strip and pagination, because that surface is not what this package provides.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 158 days ago.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The problem it solves is the plain img tag, not the gallery

Most image viewers in React are galleries. They own a list, they render their own thumbnails, and they expect you to hand them an array of sources and then place their own markup in your page. react-zmage takes the opposite position. Its stated purpose is to turn any img into an origin-expand fullscreen React image viewer, meaning the image grows from the exact rectangle it occupied on the page into the fullscreen overlay rather than fading in from nowhere.

That framing matters for a common situation. If a site renders articles from a CMS, or a docs generator outputs markdown with embedded images, or a user pastes arbitrary HTML through dangerouslySetInnerHTML, then the image tags are not yours. You cannot put a component around each one. The wrapper mode exists for exactly that case.

The package is written in TypeScript, published on npm as react-zmage, and the README describes it as drop-in with zero config. The compatibility claim is broad, React 16.8 through 19, with peer dependencies of react@>=16.8 <20 and react-dom@>=16.8 <20. The project is not archived and the last push to the repository was on 2026-05-18, with release 1.9.0 tagged the same day and 1.8.4 and 1.8.3 earlier in May.

One options bag behind three call shapes

The design decision worth understanding is that react-zmage does not have three separate APIs. It has one prop surface, named BaseType, and three ways of invoking it. The README states it plainly: all props live on a single BaseType, and the same options bag works for both the component and the imperative call.

The component is the default. It renders an img, and native attributes such as className, style, onClick and loading pass through to the underlying image, so existing styling and event handling keep working.

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

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

The imperative form opens the viewer from anywhere, including event handlers, async callbacks and third-party widgets, and it returns a destructor function for manual close.

tsx
import Zmage from 'react-zmage'

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

The wrapper form is the third shape. It queries img descendants and attaches the viewer to each one, so the config surface is unchanged but the trigger is a DOM node rather than a React element. TypeScript users can import BaseType and the sub-types, from ControllerSet and HotKey through AnimateFlip, Set and Preset.

Wrapper mode binds images at mount, so late content is missed

The wrapper is the most interesting part of the package and also the part with a real constraint. The README states that the wrapper queries img descendants in componentDidMount and componentDidUpdate, which means the binding happens as a DOM scan rather than through React's reconciler.

That has consequences. Images injected into the subtree after the wrapper renders will not get bound until the wrapper re-renders, so a component that appends markup asynchronously needs to trigger a re-render for the viewer to attach. The documentation says this directly rather than hiding it.

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

There is also a scope rule that catches people out. src and alt belong on the child img nodes, because top level src and alt are overwritten by the clicked DOM node. Viewer configuration stays on the wrapper itself, including preset, controller, hotKey, animate, gesture, backdrop, zIndex, portalTarget, radius, edge, loop, coverVisible, hideOnScroll, hideOnDblClick, loadingDelay and the lifecycle callbacks.

Gallery behaviour is opt-in through the set prop. Pass set when the wrapped subtree should behave as one shared gallery, and a clicked image whose src appears in that set opens at the matching index, with defaultPage acting only as a fallback. Without set, each image opens alone, and the caption comes from data-zmage-caption or the nearest figcaption. One asymmetry is documented: the controlled browsing prop applies to component mode and does not control the wrapper.

The SSR entry point exists because import time matters

A library that reaches for document while it is being imported will break server rendering, and react-zmage ships a separate entry for that case.

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

The API is identical, only the import path changes, and the SSR build is described as platform-neutral that avoids browser APIs at module load. The documentation reports it as verified against the Next.js 15 App Router sandbox and an Express plus Vite renderToString demo, both of which exist in the repository as packages/sandbox-nextjs and apps/demo-ssr.

For the imperative call the guidance is slightly different. If the call can run on the server, guard it with a typeof window check. The library also states that it auto-detects React 18 and above at runtime and switches to react-dom/client when that entry is available, so consumers configure nothing for the React version. Both details are small, and both remove a class of bug that would otherwise land in the application rather than the library.

The repository is a pnpm and turbo monorepo, not a single package folder

The published package and the source repository are two different shapes, and anyone planning to contribute needs to know it. The root package.json is named react-zmage-workspace, is private, and declares [email protected] as its package manager. It depends on turbo for task orchestration, and the workspace holds packages/, apps/, docs/, scripts/ and a pnpm-workspace.yaml. Two demo entry points are defined as scripts, one for the Next.js sandbox and one for the SSR demo on React 19.

bash
pnpm --filter react-zmage-demo-nextjs run dev
pnpm --filter react-zmage-demo-ssr run dev:r19

The publish path is guarded. The workspace exposes publish:core as a change into packages/core followed by npm publish, and a prepublishOnly hook runs a guard script, so an accidental publish from the root is treated as a build error rather than a release. There is also a check script that builds the core package, packs it to a tarball, refreshes the sandbox tarballs, reinstalls, and then runs check and smoke:ssr across the sandbox packages.

Beyond the code, the root holds AGENTS.md, CLAUDE.md, SKILL.md, a .cursorrules file, a .claude/ directory and a .agents/ directory. The README also points agents to a machine-readable file at zmage.caldis.me/llms.txt and tells them to keep integrations minimal. The licence field on the repository says MIT, so the code carries a permissive notice with no copyleft condition attached to reuse.

What react-zmage deliberately does not give you

There is a limit to the scope, and it is worth being explicit about it because the name invites a wrong assumption. This is a viewer for one image at a time, expanded from its origin. It is not a gallery component with a thumbnail strip, a carousel of slides, or a page router for images.

The closest it comes to multi-image behaviour is the set prop in wrapper mode, which groups images that already live in the same subtree so that clicking one opens the shared group at the matching index. That grouping is derived from a list you supply. The package does not build a group from your data layer for you, and it does not fetch a next or previous image on its own.

The interaction conventions also come from a specific era of interface design. The wrapper configuration includes gesture options for swipe, drag exit, wheel zoom, pinch zoom and double tap zoom, and hotKey handling, and it lists a controlled browsing prop, animate, preset, radius, edge and loop. Those are fine for a desktop or tablet audience, but a team that needs a bespoke mobile gallery interaction would be fighting the preset rather than building on it.

A second limit is the import cost. The stylesheet is not injected by the library, so you must import react-zmage/style.css yourself in each entry that renders an image, and any design system that expects CSS-in-JS will need a wrapper around the vendor sheet.

Where it sits against building the viewer yourself

The realistic alternative is to write the overlay yourself: a fixed position element, a transform to scale the image from its bounding box, a scroll lock on the body, a key handler for escape, and a focus trap. For a single product with one image type, that is a few hundred lines, and it gives complete control over the motion curve, the layer stacking and the mobile gestures.

react-zmage takes the opposite trade. It fixes the animation and the interaction model, and it makes the entrance work from an arbitrary source rectangle, which is the part that is fiddly to get right by hand. The origin-expand effect depends on measuring where the image already is in the viewport and animating from that rectangle to the fullscreen frame, and that is exactly the behaviour a hand-rolled implementation tends to approximate badly and then spend time on.

The other difference is where the images come from. react-zmage is built for images you did not author as components. The wrapper mode attaches a viewer to img tags inside HTML you do not control, and the imperative mode opens the viewer from a click on something that is not an image at all, such as a map marker or a third-party widget. If your images are already React elements under your control, that flexibility is not what you are buying, and the cost of a dependency with its own style sheet and its own escape, gesture and scroll behaviour may not be worth paying.

Editorial conclusion

Adopt react-zmage if your images already reach the page as markup you do not fully control, since the wrapper mode is the only route that binds a viewer to images you never authored. Skip it if you want a gallery that manages its own thumbnail strip and pagination, because that surface is not what this package provides. Before installing, check that your React version falls inside the peer range react@>=16.8 <20 and that you import react-zmage/style.css, and if you render on the server, switch the import to react-zmage/ssr so document is not touched at module load.

Frequently asked questions

How do I install react-zmage?

Install the package from npm with npm install react-zmage, or the pnpm and yarn equivalents, then import the stylesheet with import 'react-zmage/style.css'. Peer dependencies are react@>=16.8 <20 and react-dom@>=16.8 <20.

Does react-zmage work with Next.js App Router?

The documentation states that the react-zmage/ssr entry avoids touching document at import time and reports it as verified against the Next.js 15 App Router sandbox. Import from 'react-zmage/ssr' instead of 'react-zmage' in that case.

How do I open the viewer from a button instead of an image?

Use the imperative call Zmage.browsing({ src: '/photo.jpg' }) from an event handler or callback. It accepts the same props bag as the component and returns a destructor for manual close, and you should guard it with typeof window !== 'undefined' if it can run on the server.

Why are images in my wrapper not opening the viewer?

The wrapper queries img descendants in componentDidMount and componentDidUpdate, so images injected after the wrapper renders are not bound until it re-renders. Also check that src and alt sit on the child img nodes, since top level src and alt are overwritten by the clicked DOM node.

Can several images in a wrapper open as one gallery?

Pass the set prop to the wrapper so the wrapped subtree behaves as one shared gallery. A clicked image whose src appears in set opens at the matching index, and defaultPage is only the fallback when there is no match.

Official sources

  1. Caldis/react-zmage on GitHub
  2. License: MIT
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/caldis-react-zmage.svg)](https://hysenlabs.com/projects/caldis-react-zmage)

Was this useful?