Shoji

Plugins

Everything beyond the minimal lightbox is a plugin, including features you might expect to be built in (zoom, fullscreen, rotate/flip). Core stays small; plugins are opt-in, one at a time, via the plugins array.

Loading a plugin

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

Every official plugin's defaults are chosen so that just appearing in plugins is a complete, working config — no per-plugin options object required. Add one only to override something:

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

The options[plugin.name] convention

Each plugin's options nest under a top-level key matching its name — zoom: {...} for the plugin named 'zoom', layout: {...} for 'layout', and so on. This applies the same way to custom plugins you write yourself.

Official plugins

Plugin Static / option key What it does Key options (defaults)
Zoom Shoji.Zoom / zoom Pinch, double-tap/double-click, wheel/trackpad-scroll, and three toolbar buttons (zoom in/out, actual size) — pan by dragging once zoomed. Buttons are hidden entirely on a video slide (nothing to zoom). maxScale (4), doubleTapScale (2), buttonStep (1.5), mouseWheelZoom (true — any wheel event zooms, the only way to also catch a trackpad's plain two-finger drag; 'ctrl' restricts it to ctrl+wheel, false disables it)
Fullscreen Shoji.Fullscreen / fullscreen Toolbar toggle that puts the whole lightbox (not just the media) into the browser's native fullscreen mode. No button rendered at all on browsers without Fullscreen API support. none — no options object
RotateFlip Shoji.RotateFlip / rotateFlip Four toolbar buttons (rotate left/right, flip horizontal/vertical). Non-destructive CSS transform, resets every slide change. For persistent, pixel-level edits you'd need a different plugin — Shoji doesn't ship one. none — no options object
Autoplay Shoji.Autoplay / autoplay Toolbar play/pause + Space. Video slides play through to their own ended event instead of using the fixed interval; a manual pause on a video pauses the whole slideshow. Pauses by default while the viewer is actively zoomed in, interacting with rotate/flip, or reading an expanded caption — 'stop' stays paused until Play is pressed again, 'pause' resumes on its own once genuinely disengaged, false ignores it entirely. interval (5000ms), showProgress (true), autoStart (false), onZoom ('stop'), onRotateFlip ('stop'), onCaptionExpand ('stop'), stopOnManualNavigate (true)
ActiveThumbnail Shoji.ActiveThumbnail / activeThumbnail Keeps a host's own thumbnail grid in sync with whichever slide is open — toggles a class, scrolls it into view, and (unlike a plain "clear on close") leaves the class in place after the lightbox closes: the point of a visible marker is seeing it once the backdrop is out of the way, so only a genuinely different slide becoming active (or destroy()) moves or removes it. No default styling shipped for activeClass itself — write your own CSS:
.shoji-thumb-active img {
  outline: 3px solid #4da3ff;
  outline-offset: -2px;
}
(a negative offset, not positive — pulling the outline inward keeps it from spilling into a neighboring tile in a tightly-gutter'd grid, the same real bug highlight's own built-in styling below hit and fixed) — or opt into highlight: true instead of writing any CSS at all. Live in the Autoplay + ActiveThumbnail example. Also needs a data-shoji-id match per item outside selector mode — see Getting started's GalleryItem fields table.
activeClass ('shoji-thumb-active'), scrollIntoView (true), highlight (false — opt-in outline styling), borderColor ('blue', only visible with highlight: true), highlightDuration (ms, unset — opt-in auto-fade of the highlight styling, counted from close()), highlightFadeDuration (ms, 800 — how long that fade itself takes, only meaningful once highlightDuration is set)
Layout Shoji.Layout / layout Turns the inline gallery container into a managed grid/masonry/justified layout. See the dedicated guide.
Video Shoji.Video / video Renders { video: { provider: 'youtube' | 'vimeo', id } } items as a real embed — core's own native-<video> rendering only handles provider: 'html5'. Shows a poster while the embed loads: item.poster if set, else a fallback thumbnail (YouTube's predictable hqdefault.jpg URL; Vimeo's own oEmbed-fetched one), else the plain spinner. Autoplay's slideshow waits for the real player's own ended state, same as native video. none — no options object

Writing a custom plugin

A plugin is a plain object — no base class. The full shape:

const MyPlugin = {
  name: 'myPlugin',
  version: '1.0.0',   // optional — yours to track; core never reads it
  requires: [],        // optional — other plugin names that must also be in `plugins`
  defaults: { message: 'Hello from a plugin' },

  init(ctx) {
    const message = String(ctx.options.message);
    const unsubscribe = ctx.ui.toolbar('right', {
      label: message,
      onClick: () => alert(message),
    });

    return () => unsubscribe(); // cleanup, run on destroy()/reinit()
  },
};

new Shoji('#gallery', { plugins: [MyPlugin], myPlugin: { message: 'Hi!' } });
Field Required What it does
name Yes Unique identifier. The key options[name] is read from, what requires/getActivePlugins() refer to it by, and what shows up in the "unmet dependency" error if something else's requires names it and it's missing.
version No A string you set for your own tracking/debugging. Purely informational — nothing in core reads it today.
requires No string[] — the required plugins' own name strings, e.g. ['zoom'], never the plugin objects themselves (not [Shoji.Zoom]). Checked against the whole declared plugins array — position doesn't matter, a dependency can be listed before or after the plugin that needs it. See the callout just below.
defaults No This plugin's own default options — merged underneath whatever the host passed as options[name] before ctx.options is built. Omit it entirely if your plugin takes no options.
init(ctx) Yes Runs once, synchronously, at new Shoji() construction time (not lazily on first open()). Return nothing, or return a cleanup function — called on destroy()/reinit() for anything you set up by hand outside ctx (everything registered through ctx is already disposed automatically, see below).
The one easy way to get requires wrong — it fails silently (logged to the console, not thrown), which is exactly what makes it easy to miss: the dependent plugin's init() just never runs, so whatever it registers (a toolbar button, in this example) simply never appears, with no error visible unless you're looking at the console.
// Wrong — plugin objects, not their name strings:
requires: [Shoji.Zoom, MyOtherPlugin]

// Right — name strings. Position in `plugins` doesn't matter either way —
// MyOtherPlugin can be listed before *or* after ThisPlugin, both work:
requires: ['zoom', 'myOtherPlugin']
requires is checked against the whole declared plugins array, not registration order — a dependency only needs to be somewhere in the list, not listed earlier than whatever needs it. It even resolves chains correctly: if myOtherPlugin itself had an unmet requires, ThisPlugin would be skipped too, not just left silently assuming a broken dependency was fine. What never changes regardless: every plugin's init() still runs — and every ctx.ui.toolbar() button still lands — in exactly the order the plugins array itself declares.

Why declare requires at all?

requires doesn't hand you a reference to the other plugin, and it doesn't wire anything up — a plugin can call ctx.on(...) for an event another plugin emits whether or not it's listed in requires, and it works exactly the same either way once that other plugin is actually loaded. What requires buys you is a fail-fast guard for the case where it isn't loaded: instead of your plugin quietly initializing anyway and sitting there doing nothing (a subscription that never fires, a button that never updates — no error, no obvious symptom), it gets skipped up front with a clear console error naming exactly what's missing.

For example, a toolbar readout that shows the current zoom level only makes sense once Shoji.Zoom is also loaded — it works purely by listening for Zoom's own zoomChange event:

const ZoomIndicator = {
  name: 'zoomIndicator',
  requires: ['zoom'], // reads Zoom's own zoomChange event — pointless without it

  init(ctx) {
    const unsubscribe = ctx.ui.toolbar('left', { label: '100%', onClick: () => {} });
    const off = ctx.on('zoomChange', ({ scale }) => {
      // update the toolbar label to `${Math.round(scale * 100)}%`
    });

    return () => {
      unsubscribe();
      off();
    };
  },
};

Leave out requires: ['zoom'] and the code above still behaves identically whenever Shoji.Zoom is in plugins. The difference only shows up when it isn't: without requires, ZoomIndicator still initializes, its button still renders, and it just sits stuck at 100% forever — zoomChange never fires, with nothing telling you why. With requires: ['zoom'], that same misconfiguration gets skipped entirely, logged as an unmet dependency instead of a silently dead button.

What ctx gives you:

Rules worth knowing: plugins talk to each other only through events, never direct imports — requires: ['otherPlugin'] declares a hard dependency (checked against the whole declared plugins array, not registration order; a missing or failed dependency logs an error and skips that plugin, without taking down the rest). Everything registered through ctx is disposed automatically on destroy()/reinit() — the function your init() returns is for anything else you set up by hand.

Plugins init eagerly, at construction time — not lazily on first open() — so ctx.ui.* always has real DOM to act on synchronously inside init().

Checking what's loaded

gallery.getActivePlugins() returns the names of plugins that actually initialized on that instance — string[], deduplicated, excluding an invalid plugins[] entry (e.g. a stale dist build referencing a plugin static that doesn't exist) or one skipped for an unmet requires. There's no separate "official vs. custom" distinction anywhere in the API — an official plugin like Shoji.Zoom and one you write yourself go through the exact same plugins array and the exact same loading path, so this list can't and doesn't tell them apart either.

new Shoji('#gallery', { plugins: [Shoji.Zoom, Shoji.RotateFlip] });
gallery.getActivePlugins(); // ['zoom', 'rotateFlip']