Shoji

Architecture

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.

The big picture

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.

Host page new Shoji(el, options) Core (Gallery) state · DOM · event bus gestures · slide pool · a11y a PluginContext each, at construction Plugins — opt-in, in array order Zoom · Fullscreen · RotateFlip · Autoplay ActiveThumbnail · Layout · Video · yours register UI / behavior back through ctx

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's own modules

"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.

The event bus

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.

Core Plugin A Plugin B EventBus Host page

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.

Options: two layers, one merge

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.

What actually renders

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.

Distribution shape

The same source compiles to three different consumption shapes, and all three always ship together — none is allowed to lag behind the others: