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.
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():
GalleryItem has a stable, unique id.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.
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:
item.thumb, if set.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.
<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.
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.
| 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.
|
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.
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).