Shoji

Getting started

Install

Three ways to bring Shoji in, all produced by the same npm run build:

Single file (no build step)

Core plus every official plugin, in one JS file and one CSS file. Nothing activates until you opt a gallery into it via plugins: [...], so unused plugins cost nothing at runtime beyond their (small) parse/download size.

<link rel="stylesheet" href="shoji.min.css" />
<script src="shoji.min.js"></script>
<script>
  new Shoji('#gallery');
</script>

The UMD build exposes exactly one global: Shoji. It's the constructor itself (not a namespace object), and official plugins hang off it as static properties — Shoji.Zoom, Shoji.Layout, etc.

Core + individual plugin files (no build step, pick exactly what you want)

Same zero-build-step story as the single file above, but split apart — for when you want, say, Zoom and Autoplay but not the other five official plugins, without adopting a bundler just to trim them.

<link rel="stylesheet" href="core/shoji-core.min.css" />
<link rel="stylesheet" href="plugins/zoom.min.css" />
<script src="core/shoji-core.min.js"></script>
<script src="plugins/zoom.min.js"></script>
<script>
  new Shoji('#gallery', { plugins: [Shoji.Zoom] });
</script>

core/shoji-core.min.js must load before any plugins/*.min.js file — each plugin script attaches itself onto the Shoji global the core script just created (Shoji.Zoom, same as the combined bundle above), not the other way around. Plugin script tags themselves can be in any order relative to each other, and CSS order doesn't matter at all. Some plugins (RotateFlip, Fullscreen, Video) have no CSS of their own — no <link> tag needed for those. ActiveThumbnail ships CSS only for its opt-in highlight option (see the Plugins guide) — the <link> tag is only needed if you use it.

npm / ESM

npm install @michaelyagi/shoji@beta
import Shoji from '@michaelyagi/shoji';
import '@michaelyagi/shoji/style.css';

new Shoji('#gallery', { plugins: [Shoji.Zoom] });

Bundler users who want to tree-shake away plugins they don't use can import the class and plugins separately instead of pulling in the default export's static properties:

import { Gallery } from '@michaelyagi/shoji/core';
import { Zoom } from '@michaelyagi/shoji/plugins/zoom';

new Gallery('#gallery', { plugins: [Zoom] });

Zero-config quickstart

Every option in Shoji has a default chosen so this is a complete integration — no items array, no options object, no plugins:

<div id="gallery">
  <a href="photo-1-full.jpg"><img src="photo-1-thumb.jpg" alt="Sunset over the bay"></a>
  <a href="photo-2-full.jpg"><img src="photo-2-thumb.jpg" alt="Mountain trail"></a>
</div>
new Shoji('#gallery');

This works because of a default DOM→item mapping in selector mode:

DOM Item field
<a href> (or data-shoji-src) src
nested <img src> (or data-shoji-thumb) thumb
that <img>'s alt (or data-shoji-alt) alt
data-shoji-caption caption
data-shoji-width / data-shoji-height width / height
data-shoji-id id (falls back to src)
any other data-shoji-* attribute data['<name>'] — e.g. data-shoji-metadata-id="123" → item.data['metadata-id']

data-shoji-width/data-shoji-height are optional, but worth supplying whenever you know the full image's real pixel size: without them, Shoji no longer guesses at a size for the open animation or the open-transition placeholder — both are skipped entirely (plain spinner instead) rather than risk getting it visibly wrong. Supplying them is what gets you the "grows out of the thumbnail you clicked" open animation at all — see Transitions.

Any data-shoji-* attribute that isn't one of the named mappings above lands in item.data instead, keyed by the attribute name with the data-shoji- prefix stripped verbatim (not camelCased) — the same free-form bucket dynamic-mode items already use for host payloads, just populated from the DOM here:

<a href="photo-1-full.jpg" data-shoji-metadata-id="123">
  <img src="photo-1-thumb.jpg">
</a>
const MyPlugin = {
  name: 'myPlugin',
  init(ctx) {
    ctx.on('afterOpen', ({ index }) => {
      console.log(ctx.gallery.items[index].data); // { 'metadata-id': '123' }
    });
  },
};

item.data stays undefined when an item has no custom data-shoji-* attributes — this never adds an empty object. In dynamic mode there's no DOM to scan, so item.data is only ever whatever you put there yourself when constructing the item.

Each matched element also gets a click listener that opens the gallery at its index and calls preventDefault(), so a real <a href> never navigates away — the one piece of actual behavior (not just a default) the quickstart needs to work at all.

A nested <video> is auto-detected too — plain HTML5 video markup just works, no extra config:

<a href="clip.mp4">
  <video poster="clip-poster.jpg">
    <source src="clip.mp4" type="video/mp4">
  </video>
</a>

A data-shoji-video URL pointing at YouTube or Vimeo is recognized too — <a data-shoji-video="https://youtu.be/dQw4w9WgXcQ"> becomes a { provider: 'youtube', id } item automatically, and likewise <a data-shoji-video="https://vimeo.com/76979871"> becomes { provider: 'vimeo', id }; anything else falls back to a plain HTML5 source. Rendering either needs the Video plugin (Shoji.Video) — core only detects the provider, it doesn't embed one.

YouTube detection covers youtu.be/<id>, watch?v=, /embed/, /shorts/, /live/ (streams/premieres), /v/ (legacy), and youtube-nocookie.com. Vimeo detection covers vimeo.com/<id> (including an unlisted video's /<id>/<hash> privacy hash) and player.vimeo.com/video/<id>. Both are still just pattern matching against known URL shapes, not a guarantee. An optional data-shoji-video-id attribute overrides the id that would otherwise be parsed out of the URL — useful either to rescue a URL shape detection doesn't recognize, or to skip parsing entirely:

<a data-shoji-video="https://youtu.be/dQw4w9WgXcQ" data-shoji-video-id="dQw4w9WgXcQ">

The link (data-shoji-video) is still required either way — the id attribute only ever overrides the id, never the URL itself. If it's set but data-shoji-video clearly isn't a recognized YouTube or Vimeo host, it's ignored (falls back to html5) with a console warning — same if it's set without any data-shoji-video at all, or on a real <video> element (always html5, regardless).

A third attribute, data-shoji-video-provider, declares the provider outright — the DOM-mode equivalent of dynamic mode's explicit video.provider. For YouTube/Vimeo it just makes the claim explicit (still trusting the id even off a host neither recognizes, e.g. an internal proxy link); wistia isn't a supported provider at all — an unrecognized data-shoji-video-provider value is ignored with a warning and falls back to normal auto-detection:

<a
  data-shoji-video="https://youtu.be/dQw4w9WgXcQ"
  data-shoji-video-provider="youtube"
  data-shoji-video-id="dQw4w9WgXcQ"
>

GalleryItem fields

Whichever mode builds them (selector-mode scanning, above, or your own objects in dynamic mode, below), every item is the same shape. src is the only required field — everything else is optional, read where it's relevant, and simply skipped if absent:

Field Required? Used for
src yes The lightbox slide's image (or video file, for a plain HTML5 video with no sources).
thumb — The grid/tile image — Layout plugin tiles prefer this over src (see the Layout guide). Also the preferred source for the open-transition placeholder — see Transitions.
id — Two separate uses, with different fallback behavior. For updateSlides diffing, it falls back to src if omitted. For finding the origin thumbnail — used by the open/close zoom transition and ActiveThumbnail, via a data-shoji-id element matching this id — there is no fallback: omit id, or omit the matching data-shoji-id attribute on your thumbnail element, and the origin lookup returns nothing. No error is thrown; the zoom transition and ActiveThumbnail just silently don't do anything. See Transitions for what's required to enable it.
width / height — Aspect-ratio placeholder before the image loads, measure-free Layout plugin positioning, and — required, not just preferred — for the open animation and open-transition placeholder to show at all (both are skipped, plain spinner instead, without this) — see Transitions. Should be the original's dimensions, not the thumbnail's.
alt — The rendered image's alt text and the screen-reader slide announcement.
caption — Rendered below the active slide. string | HTMLElement | { dangerouslySetInnerHTML }.
srcset / sizes — Passed straight through to the slide's <img> for responsive/DPR-aware source selection.
video / sources — Makes it a video slide instead of an image; sources is one entry per <source src type>. In dynamic mode only, video also accepts the shorthand true — infers the whole descriptor from src, the dynamic-mode equivalent of a bare data-shoji-video="<url>" with no explicit id. A youtube descriptor's own id is likewise optional in dynamic mode: omit it and it's filled in from src the same way. Either way, a real id you do supply always wins and is never touched; if none can be filled in, a console warning explains why and the slide shows a placeholder instead of a broken embed.
poster — Video poster frame; also what Layout tiles fall back to (after thumb) for a video item.
data — Free-form payload for your own plugins/handlers to read — never touched by core rendering. In selector mode, also auto-populated from any unrecognized data-shoji-* attribute.
download — Declared on the type; not yet wired to any UI by core or a shipped plugin.

Dynamic mode

No DOM markup at all — pass an items array and call open(index) yourself (there's no default click target for the gallery to attach a listener to):

const gallery = new Shoji('#gallery', {
  items: [
    { id: 'p1', src: 'photo-1-full.jpg', thumb: 'photo-1-thumb.jpg', alt: 'Sunset over the bay' },
    { id: 'p2', src: 'photo-2-full.jpg', thumb: 'photo-2-thumb.jpg', alt: 'Mountain trail' },
  ],
});

document.querySelector('#my-thumb-1').addEventListener('click', () => gallery.open(0));

Update the item list at any time with gallery.updateSlides(items, currentIndex?) — items are diffed by id (falling back to src), preserving the active slide when possible. addSlides(items, atIndex?) and removeSlides(match) are sugar over the same call for the two most common cases.

Next steps

This page covers just enough to get a gallery on screen. The rest of the API has its own dedicated guides, not duplicated here: