How the pieces fit together — core, plugins, the event bus, options, and what actually ends up
on screen. This is the shape of the system for someone integrating Shoji; if you're
changing Shoji's own source, DESIGN.md in the repo root is the real spec (every
behavior, every real bug that shaped it, and why). This page is the map; that one's the
territory.
new Shoji(el, options) builds one core instance and, eagerly,
every plugin named in options.plugins — in that array's order,
which is also priority order everywhere it matters (toolbar-collapse priority, §3.1a).
Everything a plugin can affect goes back through core, either by calling
ctx.gallery's own public methods or by emitting/listening on the shared event bus
— plugins never import or call each other directly.
Plugins run init() synchronously at construction time, not lazily on first
open() — the lightbox DOM already exists (hidden) by then, so
ctx.ui.* has real elements to act on immediately, with no queueing. That's also
why a plugin like Layout can act on the inline thumbnail grid before the lightbox has ever
been opened at all — it isn't waiting for anything.
"Core" isn't one file — Gallery.ts is the orchestrator, but it delegates real
work to focused modules underneath it, none of which know about plugins at all:
| Module | Owns |
|---|---|
Gallery.ts |
Public API, lifecycle (open/close/navigate), options merging, wiring every other module together. |
dom.ts |
Builds the lightbox's actual DOM tree once — see the structure below. |
SlideManager.ts |
The slide pool — only currentIndex ± preload ever exist in the DOM (default
preload: 1, so 3 slots). Reuses/relabels slots instead of destroying and
rebuilding on every navigation.
|
GestureEngine.ts / GestureController.ts |
One Pointer-Events state machine feeding drag-to-navigate, drag-to-close, pinch, and tap detection — mouse and touch share the same code path. |
zoomTransition.ts |
The open/close zoom-from-thumbnail math (FLIP-style transform computation). |
EventBus.ts |
The typed pub/sub every gallery.on()/ctx.on() call reads from.
|
FocusTrap.ts / LiveRegion.ts |
Keyboard focus containment and screen-reader announcements — not a plugin, not optional (CLAUDE.md). |
plugin.ts |
The ShojiPlugin/PluginContext contract itself — the interface
plugins are written against.
|
One EventBus<GalleryEvents> instance per gallery. Core emits
lifecycle/slide/ gesture events (full catalog in the
Events guide); plugins can emit their own on top of it
(Autoplay's autoplayStart/autoplayStop, for example) through the
exact same bus, via ctx.emit(). A host listens with
gallery.on(event, fn) — the same method a plugin's own ctx.on()
forwards to.
Arrows point toward the bus — every side both emits and subscribes through it the same way; nothing here is a one-way broadcast. There's no line from Plugin A to Plugin B directly — that path doesn't exist.
This is also why cross-plugin dependencies are declared, not assumed:
requires: ['otherPlugin'] on a ShojiPlugin is checked against the
whole declared plugins list at construction, not registration order — a missing
or failed dependency logs an error and skips that one plugin without taking the rest of the
gallery down. No plugin can reach into another's internals even if it wanted to; the event bus
is the only channel.
GalleryOptions' own top-level fields (preload,
autoHideDelay, mode, and so on — full list in the
Settings guide) are core's alone; plugins never see
them. A plugin's own options nest under a key matching its name —
zoom: {...} for the plugin named 'zoom' — and get merged over that
plugin's own defaults before init() ever runs:
new Shoji('#gallery', {
preload: 2, // core option — GalleryOptions
plugins: [Shoji.Zoom],
zoom: { maxScale: 8 }, // plugin option — merged over Zoom's own defaults
});
Inside Zoom's init(ctx), ctx.options is already that merged result —
{ maxScale: 8, doubleTapScale: 2, buttonStep: 1.5, ... } — read-only, and scoped
to that one plugin; it can't see preload, and it can't see another plugin's
options either. Every official plugin's defaults are chosen so that just listing
it in plugins is already a complete, working config — the options object is only
ever for overriding something.
dom.ts builds this once, up front, hidden until the first open().
Plugins never build lightbox chrome themselves — ctx.ui.toolbar()/
ctx.ui.overlay() insert into slots this structure already reserves.
.shoji-outer
├─ .shoji-backdrop
└─ .shoji-dialog (role="dialog", focus-trapped)
├─ .shoji-toolbar
│ ├─ .shoji-toolbar-left ← ctx.ui.toolbar('left', …)
│ ├─ .shoji-toolbar-center ← ctx.ui.toolbar('center', …)
│ └─ .shoji-toolbar-right ← ctx.ui.toolbar('right', …), close button always last
├─ .shoji-slides (SlideManager's pool — preload × 2 + 1 slots)
│ └─ .shoji-slide × N (only one holds offset 0, the active slide)
├─ .shoji-nav-prev / .shoji-nav-next
├─ .shoji-caption (+ .shoji-caption-modal when truncated, §2.3a)
└─ .shoji-toolbar-overflow-panel (collapsed toolbar buttons, §3.1a)
Every visual property is a --shoji-* CSS custom property (full catalog in the
Theming guide) — core and every official plugin read from
these instead of hardcoding colors/sizes, so a host re-themes by overriding custom properties,
never by fighting specificity.
The same source compiles to three different consumption shapes, and all three always ship together — none is allowed to lag behind the others:
dist/shoji.js + shoji.css, and their
.min pair) — core and all seven official plugins in one file, self-registering.
Shoji is a single UMD global; official plugins are attached as static
properties (Shoji.Zoom) via
Object.assign(Gallery, { Zoom, Fullscreen, ... }) — this is what a plain
<script> tag gets, and what the demo/docs site itself loads.
dist/core/shoji-core.js +
.css, and one dist/plugins/{name}.js + .css pair per
official plugin) — for a <script>-tag host that wants to pick exactly
which plugins ship without adopting a bundler. Each of these is its own separately-bundled
UMD file; a plugin file attaches itself onto the already-loaded Shoji global
via a dotted name (Shoji.Zoom) and Rollup's extend: true, the same
mechanism as the single-file build's own attachment, just one script tag at a time. Because
each file is a genuinely separate bundle, a plugin can never rely on sharing module-level
state with core by importing an internal core module directly — it has to go through the
Gallery instance itself (ctx.gallery), which is the one thing
guaranteed to be the same object regardless of which distribution shape loaded it. See
Getting Started for the integration snippet.
src/core/index.ts plus a separate entry
per official plugin (shoji/plugins/zoom, etc.), for a bundler-based host that
only wants the plugins it actually uses compiled in.