An image in a page is expected to do one thing when you press it: get bigger. So that is what this does with no props at all, and the wheel zooms once it is open. Everything past that - moving through a set, the strip along the bottom, a menu that downloads the file, throwing one away - is a decision about the page it sits in, so each is a prop you turn on rather than one you have to remember to turn off.
Usage
import { Image } from "@enigmax/primitives/react/image";
// A press opens it, the wheel zooms, the arrows and the strip are not there.
<Image src="/shot.png" alt="The dashboard" />
// A gallery: the same component, told what the set is.
<Image
src={photos[0].src}
alt={photos[0].alt}
images={photos}
navigation
thumbnails
/>
images takes a URL or an object per picture, so a set can carry more than a source:
const photos = [
{ src: "/full/1.jpg", thumbnail: "/thumb/1.jpg", alt: "The lobby", caption: "Ground floor" },
{ src: "/full/2.jpg", thumbnail: "/thumb/2.jpg", alt: "The stairs", download: "/original/2.tif" }
];
thumbnail is used in the strip and while the full picture loads; download is the file the
menu saves when it is not the one on screen.
What is on, and what is not
| Prop | Default | |
|---|---|---|
lightbox |
true |
A press opens the viewer |
zoom |
true |
Wheel, pinch, double press, and + - 0 |
navigation |
false |
Arrows over the picture, a counter, and Left/Right |
thumbnails |
false |
The strip along the bottom |
menu |
false |
The three dots, with Download under them |
discardable |
false |
Take this one out of the set and move to the next |
animate |
true |
The picture flies out of the page and back into it |
loop |
true |
The arrows and the strip wrap at the ends |
styles |
true |
The viewer’s stylesheet |
zoom also takes { min, max, wheel, doubleClick } when the defaults are wrong for your
picture, and menu takes { download, items, onSelect } to add rows of your own.
It flies out of the page
Pressing a picture does not put a dialog over it: the picture itself grows out of where it was sitting and settles in the middle, and closing takes it home. The same move react-medium-image-zoom made the standard one, done here without the dependency.
It is FLIP, and the order matters: the full-size picture is laid out where it belongs, measured, then drawn back onto the thumbnail and released. Doing it the other way round would need the destination size before layout has produced it. Two consequences worth knowing about, because both are silent when they go wrong:
- The picture is not drawn at all until it has been measured. Drawing during that beat puts one frame of the full-size image where it is going to land, and the flight then starts by jumping backwards.
- The transition is on only while it is moving, never while it is being placed. Placing the picture on the thumbnail is itself a transform change, so a transition left on animated that too - invisibly - and by the time anybody could see the picture the flight was nearly over.
animate={false} turns it off, and so does the reader’s own
prefers-reduced-motion: reduce - a dialog that simply exists is what that preference asks
for. The duration and the curve are custom properties, so slowing it down is a line of CSS
rather than a prop; the viewer reads the real duration off the element rather than assuming its
own default.
The flight home only happens onto this picture. Move through a set to the fourth image and close, and the viewer closes without one: the component knows where its own picture is and nothing about anyone else’s, so flying the fourth back onto the first would be a lie.
The cursor says what a press does
Three different things in one dialog, so three cursors: zoom-in on the picture in the page
and on the picture at rest in the viewer, zoom-out on the dark area beside it - which is the
backdrop, and closes - and grab once there is somewhere to pan to.
Zoom that stays where you put it
The wheel zooms around the cursor, not around the middle of the frame. Scaling from the centre slides whatever you were pointing at out from under you, so zooming into a face means zooming and then hunting for it again.
Three more things the gesture needs, all done here. The wheel listener is not passive, so a notch zooms the picture instead of also scrolling the article behind the backdrop. The pan is bounded by how much of the image actually hangs outside the frame, so a drag cannot strand it off screen. And a notch is the same size in every browser: Firefox reports a wheel in lines and a page scroll in pages, so treating every delta as pixels makes the same gesture a hundred times stronger in one of them.
On touch it is a pinch, and the keyboard drives all of it: + and - zoom, 0 fits, and the
arrows pan once there is somewhere to pan to.
A set, and taking one out of it
discardable adds the bin to the toolbar and answers Delete. The list stays yours: the
viewer skips what was discarded for as long as it is open and tells you which one it was, so
you drop it from your own state - and if it was the last one, the viewer closes rather than
sitting over an empty frame.
<Image
src={photos[0].src}
alt={photos[0].alt}
images={photos}
navigation
thumbnails
discardable
onDiscard={(item) => setPhotos((current) => current.filter((photo) => photo.src !== item.src))}
onIndexChange={(index, item) => track("viewed", item.src)}
/>
The menu, and what it does with the file
menu is the three dots, and what opens under it is
the context menu with a left press instead of a right one - the same panel,
keyboard and roles, rather than a second popup written for one toolbar.
Two rows come with it: Download, and Open in a new tab for the picture on its own.
menu={{ download: false }} or { newTab: false } drops either.
Opening a tab is not window.open and a shrug. Chromium refuses a top-level navigation to a
data: URL - it was a phishing vector - so a picture that IS one, which is every generated
placeholder and every inlined thumbnail, opened nothing at all and said nothing about it. Those
are handed over as a blob instead, decoded synchronously, because a window opened after an
await is a popup with no gesture behind it and the browser blocks that too.
Download is the row it comes with. It fetches the file and hands over the blob, because
<a download> is honoured only for a same-origin URL: on a CDN the browser ignores the
attribute and opens the picture in a tab, which is not what the row said it would do. Where
the fetch is refused for want of a CORS header, the plain anchor is the fallback.
<Image
src={photo.src}
alt={photo.alt}
menu={{
items: [{ id: "report", label: "Report this image" }],
onSelect: (id, item) => { if (id === "report") report(item.src); }
}}
/>
What loads, and when
The viewer is its own chunk and the menu another under it, so a page of images nobody enlarges downloads neither. The chunk is fetched on intent - a pointer over the picture, or focus reaching it - so by the time the press lands it is usually already there, and a press that beats it opens the viewer as soon as it arrives instead of being dropped.
What the page renders is never in the chunk: the <img> and the press are this module, which
is what keeps the affordance from appearing late.
Installation
Adds the package and prints the import to use. This is the way to use them: the package keeps getting fixes, and a dependency is how they reach you.
enigma add imageInstalls with your project's own package manager - npm, pnpm, yarn or bun - read from its packageManager field or its lockfile.
--copy writes the source into your project instead. Reach for it to EDIT a component, not to use one: a copy is frozen at today's version and stops receiving fixes.
1 Install the dependencies
2 Use it
Import from @enigmax/primitives/react/image.
The viewer brings a theme, for the reason the toast and
the colour picker do: an unstyled lightbox is not a plain one, it is the page with an
image lying across it. styles={false} turns it off, and
@enigmax/primitives/image.css is the same sheet if you would rather import it.
Every colour and distance is a custom property:
:root {
--enigma-image-backdrop: rgba(0, 0, 0, 0.86);
--enigma-image-control-bg: rgba(23, 23, 23, 0.72);
--enigma-image-control-size: 2.25rem;
--enigma-image-thumb-size: 3.5rem;
--enigma-image-flight: 300ms;
--enigma-image-flight-ease: cubic-bezier(0.2, 0, 0.2, 1);
--enigma-image-z: 80;
}The sheet is injected by the picture in the page, not by the viewer’s chunk - so the cursor says it enlarges, and the focus ring exists, before anybody has opened one.
Styling hooks
| Attribute | On |
|---|---|
[data-enigma-image] |
The wrapper in the page - data-clickable when it opens |
[data-enigma-image-trigger] |
The button around the picture |
[data-enigma-image-viewer] |
The dialog, portalled to the body - data-state is opening, open or closing. Its menu is portalled to the same body and sits above it |
[data-enigma-image-bar] |
The toolbar, and [data-enigma-image-counter] in it |
[data-enigma-image-frame] |
The frame - data-zoom, data-zoomed, data-panning, data-loading, and data-flying while it is pending or moving |
[data-enigma-image-nav="previous"], [data-enigma-image-nav="next"] |
The arrows |
[data-enigma-image-strip], [data-enigma-image-thumb] |
The previews. aria-current marks the one showing |
[data-enigma-image-caption] |
The line under the picture |
What it gives back
| Keyboard | Escape closes, + - 0 zoom and fit, arrows navigate or pan, Delete discards |
| Focus | Into the frame on open, back to the picture on close |
| The page behind | Cannot scroll while the viewer is up |
| Screen readers | role="dialog", aria-modal, the picture’s own alt as the trigger’s name |
| A press beside the picture | Closes it, the way every lightbox does - and a drag that ends there does not |