Plugin System
The Triiiceratops plugin system: component-based extensions that render as docked sidebar panels or as flyouts over the canvas.
Triiiceratops has a component-based plugin system for extending the viewer. A plugin renders its UI either as a panel — docked to a sidebar, banded below the image, or floated over it — or as a compact flyout popover anchored to its toolbar button.
How the Plugin System Works
Each first-party plugin is its own independently versioned npm package under the @triiiceratops scope, and ships in two delivery formats.
Format | Use case | Plugins delivered via |
|---|---|---|
IIFE | Static HTML pages, no build step | Script tags + the |
ES Modules | Front-end framework projects (any bundler) |
|
ES Modules — for bundler projects (React, Vue, Svelte, Lit, or any other framework — Vite, webpack, Rollup, …), import the plugin's factory from its scoped package and pass it to the viewer. Plugins run in the page's realm and receive the live
ViewerStatedirectly. Every plugin's ESM build leavessvelteexternal as an ordinary peer, so your bundler dedupes it against core's copy the way it dedupes any other shared dependency.IIFE — each plugin's IIFE (
@triiiceratops/plugin-*/dist/iife.js) registers a factory into the shared, order-independentwindow.Triiiceratops.pluginsregistry. Loading a script does not activate the plugin; activation is explicit and per-viewer. Scripts may load in any order, except@triiiceratops/plugin-av— see below.
Script order: @triiiceratops/plugin-av loads after core
Every plugin IIFE but one bundles its own Svelte runtime, and may therefore load before or after the core script. @triiiceratops/plugin-av is the exception: it reads core's Svelte runtime off window.Triiiceratops instead of shipping a second copy of it, which keeps roughly 12 KB gzip off every page that loads it. So core's script must run first:
<script src="triiiceratops-element.iife.js"></script>
<script src="triiiceratops-plugin-av.iife.js"></script>Getting it wrong is a named diagnostic, not a broken page: the plugin's bundle checks the namespace before it evaluates anything, logs one console.error saying which half is missing, and does not register — so window.Triiiceratops.plugins.get('@triiiceratops/plugin-av') returns undefined. A core too old to share a runtime is refused the same way, and a core that shares a runtime but does not declare the shared-svelte-runtime capability is refused at activation with a PluginCompatibilityError.
This is a first-party-only arrangement. svelte/internal is private, unversioned API, and the guarantee behind sharing it is that core and the plugin are built and released from one repository at one Svelte version — which is why the plugin pins coreRange to an exact core version. A third-party plugin is released on its own schedule, so it must bundle its own Svelte runtime, as the plugin authoring guide describes.
Adding a plugin to your viewer
A plugin is handed to the viewer through its plugins list. How you set that list depends on how the viewer is embedded:
React — the
pluginsprop of<TriiiceratopsViewer>fromtriiiceratops/react.Vue — the
:pluginsprop of<TriiiceratopsViewer>fromtriiiceratops/vue.Svelte — the
pluginsprop of<TriiiceratopsViewer>.Everything else (vanilla JS, plain HTML, other frameworks) — the
.pluginsproperty of the<triiiceratops-viewer>web component.
Plugins are plain objects, so they cannot go through an HTML attribute; the web component always receives them as a JavaScript property. (The React and Vue wrappers do that assignment for you, and do it correctly whether or not the element has upgraded yet.) Registering a plugin package does not activate it — activation is per-viewer and happens when the list is assigned.
Activation lifetime is keyed to plugin identity, not to the identity of the list: re-supplying an equal list leaves running plugins completely untouched, so a parent re-render never tears down and restarts your plugins.
Every example below adds @triiiceratops/plugin-image-manipulation; each plugin is added the same way.
With a bundler, import the registration entry once to define the element, then set .plugins on it:
import 'triiiceratops/element/register';
import { ImageManipulationPlugin } from '@triiiceratops/plugin-image-manipulation';
// `viewer` is your <triiiceratops-viewer> element.
viewer.plugins = [ImageManipulationPlugin];With no bundler at all, plugin IIFEs register a factory into the shared, order-independent window.Triiiceratops.plugins registry; the scripts may load in any order:
<script src="https://unpkg.com/triiiceratops/dist/triiiceratops-element.iife.js"></script>
<script src="https://unpkg.com/@triiiceratops/plugin-image-manipulation/dist/iife.js"></script>
<triiiceratops-viewer manifest-id="https://example.org/manifest.json"></triiiceratops-viewer>
<script>
customElements.whenDefined('triiiceratops-viewer').then(() => {
const viewer = document.querySelector('triiiceratops-viewer');
viewer.plugins = [
window.Triiiceratops.plugins.get(
'@triiiceratops/plugin-image-manipulation',
),
];
});
</script>A typed prop on the React wrapper. Build the list once — a hoisted constant or a useMemo — so its contents keep their identity.
import { TriiiceratopsViewer } from 'triiiceratops/react';
import { ImageManipulationPlugin } from '@triiiceratops/plugin-image-manipulation';
const plugins = [ImageManipulationPlugin];
export function Viewer() {
return (
<TriiiceratopsViewer
manifestId="https://example.org/manifest.json"
plugins={plugins}
style={{ display: 'block', height: '600px' }}
/>
);
}A typed prop on the Vue wrapper — no isCustomElement compiler option and no onMounted assignment.
<script setup lang="ts">
import { TriiiceratopsViewer, type SdkPlugin } from 'triiiceratops/vue';
import { ImageManipulationPlugin } from '@triiiceratops/plugin-image-manipulation';
const plugins: readonly SdkPlugin[] = [ImageManipulationPlugin];
</script>
<template>
<TriiiceratopsViewer
manifest-id="https://example.org/manifest.json"
:plugins="plugins"
style="display: block; height: 600px"
/>
</template><script>
import { TriiiceratopsViewer } from 'triiiceratops/svelte';
import 'triiiceratops/style.css';
import { ImageManipulationPlugin } from '@triiiceratops/plugin-image-manipulation';
</script>
<TriiiceratopsViewer
manifestId="https://example.org/manifest.json"
plugins={[ImageManipulationPlugin]}
/>Multiple and configured plugins
plugins is a list — add as many as you like. A plugin that takes options exposes a create* factory; call it and pass the result. See the available plugins reference.
import { ImageDownloadPlugin } from '@triiiceratops/plugin-image-export';
import { createPdfExportPlugin } from '@triiiceratops/plugin-pdf-export';
viewer.plugins = [ImageDownloadPlugin, createPdfExportPlugin()];Panels and flyouts
A panel docks at one of four positions. left and right put it in that sidebar stack, where panels on the same side stack vertically and the side's width is set once by leftPanelWidth / rightPanelWidth — there is no per-plugin width. bottom is a full-width band below the image in the center column. overlay floats the panel over the image itself, inside the viewer area.
A flyout is a compact popover anchored to the plugin's toolbar button. It opens on click (click-outside / Esc to dismiss) and grows toward the canvas so it never opens off-screen. It stays mounted while closed, so background work keeps running. Use a flyout for a few compact controls; use a panel when the UI needs more room.
Panels behave the same way: a plugin is mounted once per viewer and stays mounted while its surface is closed. A plugin that wants to pause while it is not visible reads context.surface.isOpen — see knowing whether your panel or flyout is open.
The authored target is only a default. Every plugin registers both a panel and a flyout entry, so the effective target is switchable at runtime — like visible/open — via config.plugins[id].target or viewerState.setPluginTarget(id, target). A panel's dock position works the same way: config.plugins[id].position or viewerState.setPluginPosition(id, position) sets it for any plugin as a consumer-only decision; definePlugin itself has no position field, so a plugin author cannot fix one. This lets one plugin render as a panel on desktop and a flyout on a narrow viewport; see controlling plugin UI at runtime below for the per-framework code. Switching remounts the plugin UI in the new container, so a plugin that must survive the switch keeps its state in viewer state or its own store, not in local component state.
Controlling Plugin UI Through Config
Plugin toolbar button visibility and plugin panel open/closed state can be controlled through the same config object used for built-in panes.
Configuration shape:
type ViewerConfig = {
plugins?: Record<
string,
{
visible?: boolean; // show/hide the plugin toolbar button
open?: boolean; // open/close the plugin panel
showCloseButton?: boolean; // Default: true
target?: 'panel' | 'flyout'; // override where the plugin renders
position?: 'left' | 'right' | 'bottom' | 'overlay'; // override the panel's dock position
}
>;
};The record key is the plugin's stable id — its uiId. First-party plugins set short, documented ids (av, annotation-editor, pdf-export, image-download, image-manipulation). If a plugin omits uiId, core derives a stable id from its package name by replacing every run of unsafe characters with - (e.g. @scope/plugin-foo → scope-plugin-foo).
Every field is a sparse override applied on top of the plugin's authored defaults; omitting a field leaves the current live value untouched:
visible: falsehides only the plugin's toolbar button. A plugin can also hide its own button on a canvas it has nothing for (surface.setAvailable, which closes an open surface as it goes); both must agree for the button to render.open: trueopens the plugin's surface if it is registered.showCloseButton: falseremoves the close button from the plugin's docked panel header, and with it the Escape-to-close path — one flag, not two. The default istrue, matching every core panel.target: 'flyout' | 'panel'moves the plugin between its docked panel and its anchored flyout; the switch remounts the plugin UI.position: 'left' | 'right' | 'bottom' | 'overlay'docks the panel somewhere else — a consuming app's own choice, independent of whatever the plugin was authored with. Ignored while the effectivetargetis'flyout', since a flyout is anchored to its toolbar button rather than docked.
Update config and the change applies reactively.
Controlling plugin UI at runtime
A common use is switching a plugin to a flyout on narrow viewports, or docking it to whichever side fits your layout:
Assign a new config object on the element:
const mq = window.matchMedia('(max-width: 640px)');
const sync = () => {
viewer.config = {
plugins: {
'image-manipulation': { target: mq.matches ? 'flyout' : 'panel' },
},
};
};
sync();
mq.addEventListener('change', sync);The element also exposes its live viewer state through the getter-only viewerState property (see the state bridge), so el.viewerState?.setPluginTarget(id, target) is available too. config is the declarative option; the bridge is the imperative one.
Either pass a new config object, or call the command through the handle:
import { useEffect, useState } from 'react';
import {
TriiiceratopsViewer,
useViewer,
useViewerHandle,
} from 'triiiceratops/react';
export function Viewer() {
const handle = useViewerHandle();
const viewer = useViewer(handle);
const [narrow, setNarrow] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(max-width: 640px)');
const sync = () => setNarrow(mq.matches);
sync();
mq.addEventListener('change', sync);
return () => mq.removeEventListener('change', sync);
}, []);
useEffect(() => {
viewer?.setPluginTarget(
'image-manipulation',
narrow ? 'flyout' : 'panel',
);
}, [viewer, narrow]);
return (
<TriiiceratopsViewer
handle={handle}
manifestId="https://example.org/manifest.json"
style={{ display: 'block', height: '600px' }}
/>
);
}Either pass a new config object, or call the command through the template ref:
<script setup lang="ts">
import { computed, useTemplateRef, watchEffect } from 'vue';
import {
TriiiceratopsViewer,
useViewer,
type TriiiceratopsViewerInstance,
} from 'triiiceratops/vue';
const viewer = useTemplateRef<TriiiceratopsViewerInstance>('viewer');
const state = useViewer(viewer);
const mq = window.matchMedia('(max-width: 640px)');
const target = computed(() => (mq.matches ? 'flyout' : 'panel'));
watchEffect(() => {
state.value?.setPluginTarget('image-manipulation', target.value);
});
</script>
<template>
<TriiiceratopsViewer
ref="viewer"
manifest-id="https://example.org/manifest.json"
style="display: block; height: 600px"
/>
</template>Reassign the reactive config prop:
<script>
import { TriiiceratopsViewer } from 'triiiceratops/svelte';
import { ImageManipulationPlugin } from '@triiiceratops/plugin-image-manipulation';
let narrow = $state(false);
$effect(() => {
const mq = window.matchMedia('(max-width: 640px)');
const sync = () => (narrow = mq.matches);
sync();
mq.addEventListener('change', sync);
return () => mq.removeEventListener('change', sync);
});
let config = $derived({
plugins: {
'image-manipulation': { target: narrow ? 'flyout' : 'panel' },
},
});
</script>
<TriiiceratopsViewer manifestId="…" plugins={[ImageManipulationPlugin]} {config} />Svelte hosts that capture viewerState (via bind:viewerState) can also call viewerState.setPluginTarget(id, target), setPluginPosition(id, position), and setPluginOpen(id, open) imperatively.
Defining a plugin
Plugins are framework-agnostic. Author new plugins with the framework-neutral SDK (@triiiceratops/plugin-sdk) and definePlugin: a plugin mounts into a plain HTMLElement, so you can render it with vanilla JavaScript, React, Vue, Svelte, Lit, or a custom element, and it behaves the same in every host. definePlugin gives you a mount contract; the live ViewerState for reads and supported commands; root-aware style, locale, and UI services; failure isolation; and a conformance test kit.
See the plugin authoring guide and the plugin testing guide for the full API and examples.
definePlugin is the only plugin path — there is no Svelte-only shortcut, and a Svelte host mounts its component from the SDK's mount() like every other framework. See the Svelte tab in rendering UI in your framework.
Available Plugins
Plugin | What it does | Renders as |
|---|---|---|
Plays a canvas's | Panel (transcript) + canvas stage | |
Draws and edits a canvas’s annotations — rectangle, ellipse, polygon, point and whole-canvas tools, keyboard parity, and a storage adapter the host supplies | Panel (or flyout) | |
Brightness, contrast, saturation, invert, and grayscale controls for the displayed image | Flyout | |
Downloads the current canvas (composite, single image, or current view) as a raster image | Panel | |
Exports a range of canvases as a browser-generated PDF, with optional OCR text and a cover sheet | Panel |
Each page above has its own install command, setup snippet, and configuration reference.
Package Exports Reference
Export path | Description |
|---|---|
| Core Svelte component and utilities |
| Core stylesheet (Svelte usage) |
| Version-neutral IIIF canvas helpers — see the canvas contract |
| Svelte 5 component — a superset of the root entry |
| |
| |
| Framework-neutral selector runtime |
| Headless viewer state + |
| Web Component self-contained IIFE |
| Web Component ESM registration |
| Plugin SDK (base) |
| Browser/IIFE registration ( |
| Browser/IIFE registration for a plugin that cannot load before core — without a shared-runtime load-order gate it silently no-ops with no diagnostic, so third-party plugins should keep using |
| SDK framework adapters |
| SDK plugin test kit |
| Audio & video plugin (ES module) |
| Audio & video plugin (IIFE — serve the whole |
| Annotation editor plugin (ES module) |
| Adapter conformance suite — see the conformance suite |
| Annotation editor plugin (IIFE — either script order) |
| Image manipulation plugin (ES module) |
| Image manipulation plugin (IIFE) |
| Image download plugin (ES module) |
| Image download plugin (IIFE) |
| PDF export plugin (ES module) |
| PDF export plugin (IIFE) |