Image

A picture that opens into a viewer - it flies out of the page, the wheel zooms, a drag pans. The gallery arrows, the strip of previews, the download menu and the discard action are off until you ask.

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 image

Installs 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.

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