Shoji

Transitions

mode (a GalleryOptions string, default 'slide') controls the slide-to-slide animation for programmatic navigation — buttons, keyboard, goTo(), autoplay. It never applies to a gesture-driven swipe, which always plays its own live-drag/settle animation regardless of mode — a completed swipe already looks and feels like a transition, so layering a second one on top would double-animate.

new Shoji('#gallery', { mode: 'fade' });

This is a separate system from the open/close animation (the photo growing out of its thumbnail, and shrinking back into it) — that one plays the same FLIP-style zoom regardless of mode, which only ever governs switching between slides while already open.

Enabling the zoom-from-thumbnail transition

Unlike slide-to-slide transitions, the open/close zoom is not unconditional. It needs an origin element — the actual thumbnail DOM node to animate to/from — and Shoji has no reliable way to find one on its own in dynamic mode (items passed as JS objects rather than scanned from existing markup). If no origin is found, the lightbox just appears and disappears instantly, with no animation and no error.

To enable it, both of the following must be true at the moment you call open():

  1. Each GalleryItem has a stable, unique id.
  2. The corresponding thumbnail element in your page has a matching data-shoji-id="<that id>" attribute, and is actually present in the DOM (a virtualized/windowed grid that unmounts off-screen thumbnails will break this for anything not currently mounted).
const items = photos.map((p) => ({ id: p.metadataId, src: p.url }));

// in your thumbnail grid:
// <img data-shoji-id="${photo.metadataId}" src="${photo.thumbUrl}" />

Selector mode (scanning existing markup instead of passing dynamic items) gets this for free — the scanned element itself is the origin, no data-shoji-id needed.

Open-transition placeholder

On a fresh open(), if the real content isn't decoded yet (a slow connection, a large photo), a spinner shows by default — same as navigating to any other undecoded slide. If a low-res thumbnail is available for that item and you've supplied item.width/height (see below for why both are required), Shoji shows that instead, growing into place alongside the zoom-in animation, then swaps to the real image the instant it finishes decoding (mid-animation if it lands there). This only ever applies to open() — ordinary slide-to-slide navigation always uses the spinner, since there's no low-res version of a slide you haven't opened yet to reuse the same way.

Thumbnail source, checked in this order, first match wins:

  1. item.thumb, if set.
  2. A live data-shoji-thumb attribute on the origin element (see above) — lets a dynamic-mode host opt in by tagging existing markup, with no item-array change needed.
  3. Whatever <img> is already rendered inside the origin element.

If none apply — or dimensions aren't known — it's the ordinary spinner, never a broken/blank state.

Selector mode gets the thumbnail source for free too: thumb auto-populates from the scanned <img src> (or a data-shoji-thumb override) at scan time, so the first tier above already covers it. You'll still need to supply data-shoji-width/data-shoji-height yourself, though — there's no way to scan those from a thumbnail image alone.

Sizing the open animation and placeholder

Two separate things need a size to show/grow toward before the real image has loaded and Shoji actually knows how big it is: the zoom-in animation itself (the thumbnail growing into place), and — independently — the open placeholder shown while the real image is still decoding.

Both require item.width/height — there's no fallback, and no guessing. A thumbnail's own dimensions say nothing reliable about how big the real photo actually is, so without explicit dimensions Shoji has no way to size either one accurately. Earlier versions guessed instead — assuming the photo was probably big enough to fill the dialog, true most of the time, but visibly wrong for a genuinely small photo (it would grow/stretch to fill the dialog, then snap back down to its true size once the real image loaded and its own CSS sizing, which never grows an image past its native resolution, took over). That guessing was removed rather than fixed further: without known dimensions, open() now shows the ordinary spinner and skips the zoom-in animation entirely, revealing the real photo the moment it's ready with no animation of its own — nothing left to grow toward once it's already loaded, so there's nothing lost by not guessing.

Supplying item.width/height (or data-shoji-width/data-shoji-height in selector mode) is what gets you the full experience: the placeholder renders at, and the animation grows toward, the photo's actual size from the very first frame — no guessing, no correction needed, and the "grows out of the thumbnail you clicked" motion on open. See Thumbnails + originals for a working example.

Built-in presets

Family Names Look
Slide slide (default), slideVertical, push Directional — mirrors for next vs. prev. push travels further with no cross-fade, a harder "shove it off" feel.
Fade fade, fadeUp, fadeDown, fadeLeft, fadeRight fade is a plain cross-fade. The directional ones are fixed — a fixed fadeUp always rises, regardless of nav direction.
Zoom zoom, zoomOut, scaleUp, scaleDown Incoming/outgoing scale in opposite directions, with a cross-fade.
Rotate rotate, rotateLeft, rotateRight 2D rotateZ spin. rotate is fixed-direction; rotateLeft/rotateRight tie their spin to nav direction.
3D flipX, flipY, cube, coverflow Real depth via a CSS perspective(). cube/coverflow are directional.
Other deck, drop deck: the incoming slide drops in from slightly above/small while the outgoing one recedes and fades, like placing a card on a deck — not direction-dependent. drop: a fixed vertical fade.

Custom transitions

Any mode string that isn't one of the built-in names above is treated as a CSS class pair, not an error — no JS needed on your end:

new Shoji('#gallery', { mode: 'my-wipe' });
.shoji-transition-my-wipe-leave {
  transition: transform var(--shoji-duration) var(--shoji-easing);
  transform: translateX(-100%);
}
.shoji-transition-my-wipe-enter {
  transition: transform var(--shoji-duration) var(--shoji-easing);
  transform: translateX(0);
}

-leave is added to a temporary clone of the outgoing slide (positioned over the real one while the actual content swap happens invisibly underneath it), -enter to the real incoming slide — both classes get a data-shoji-direction="next"/"prev" attribute too, if your CSS needs to tell the two apart (e.g. [data-shoji-direction="prev"] { ... }). Whichever of transitionend/animationend fires first (plus a timeout fallback) tells Shoji the animation is done and cleans up — a plain CSS transition or a @keyframes animation both work.

Timing, easing, and reduced motion

Duration and easing are CSS custom properties — --shoji-duration/--shoji-easing, documented on the Theming guide — not a separate JS-side timing mechanism. transitionDuration (a GalleryOptions number, in ms) is a convenience for setting --shoji-duration from JS instead of your own CSS, same idea as backdropOpacity for --shoji-backdrop-opacity — leave it unset and a host theming --shoji-duration directly is unaffected either way. Every built-in preset, and the transitionend-based cleanup for custom ones, respects prefers-reduced-motion: reduce automatically — content just swaps instantly, no animation attempted.

The active caption fades alongside the transition too — out over the first half of --shoji-duration, back in over the second half once the new slide's content has swapped in, so both finish together rather than the caption trailing a beat behind. The very first open() fades its caption in the same way, alongside the zoom-from-thumbnail animation, whenever there's a known size to zoom from at all (see Getting started's GalleryItem fields table).