Any framework (web component)
Drop the <triiiceratops-viewer> custom element into plain HTML, a Django or WordPress template, or any framework that can render a tag.
Triiiceratops ships the viewer as a standards-based custom element, <triiiceratops-viewer>. If your stack can put a tag on a page, it can host the viewer with no adapter.
This guide covers Angular, Lit, Solid, vanilla JavaScript, Alpine.js, htmx, Astro, Ember, jQuery, a server-rendered template language (Django, Rails, Laravel, ASP.NET, PHP), a static-site generator, and WordPress (as a block, a shortcode, or a raw HTML widget). Each takes the same two steps: get the script onto the page, then set attributes and properties on the tag.
The element comes in two forms:
ESM registration (
triiiceratops/element/register) for projects with a bundler (Vite, webpack, Rollup, …).Self-contained IIFE (
triiiceratops/element) for no-bundler pages loaded from a<script>tag.
Both register the same tag and expose the same properties, methods, and events. Styles and themes are installed inside the element's shadow root. There is no separate element stylesheet to import.
Install
pnpm add triiiceratopsnpm install triiiceratopsbun add triiiceratopsNo-bundler pages skip this step; see the HTML tab below for loading straight from a CDN <script> tag.
Use it
Load the self-contained IIFE from a CDN or a copied file, with no install or build step. It bundles the element, the Svelte runtime, the image renderer, and all styles:
<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"
style="display: block; width: 100%; height: 100vh;"
></triiiceratops-viewer>
<script>
customElements.whenDefined('triiiceratops-viewer').then(() => {
const viewer = document.querySelector('triiiceratops-viewer');
const plugin = window.Triiiceratops.plugins.get(
'@triiiceratops/plugin-image-manipulation',
);
viewer.plugins = [plugin];
});
</script>That snippet is the entire integration for a WordPress custom-HTML block, a Django or Rails template, or any page you can add a <script> tag to.
Using a bundler instead? Register the element with import 'triiiceratops/element/register', then set properties directly: document.querySelector('triiiceratops-viewer').manifestId = …. Outside a component framework you need no ref.
Loading the same core version twice is a harmless no-op.
Loading a different core version alongside one already registered leaves the first registration and custom element untouched and throws an actionable conflict error. Side-by-side multi-version loading on one page is not supported.
Registering a plugin whose name is already registered keeps the first factory; a version mismatch is reported as a console warning (first-wins), not an error.
The element runs under a strict CSP without unsafe-eval. See the CSP recipe.
From there: add plugins, configure the UI, or theme it.
Bundling and load performance
Keep the published size
The element ships minified for an es2022 target. A bundler that re-minifies it undoes part of that work: under Vite’s default target the element grows by about 11 KB Brotli, from downlevelling modern syntax and re-mangling names. Set the build target to es2022 to skip the downlevelling:
import { defineConfig } from 'vite';
export default defineConfig({
build: { target: 'es2022' },
});The same target saves apps that import triiiceratops/svelte about 3.3 KB Brotli.
To avoid re-minification entirely, keep the element out of the bundle and load the ESM file as a static module script, from a CDN or a copy of node_modules/triiiceratops/dist/triiiceratops-element.js served as-is. It is a single self-contained file. The React and Vue wrappers skip importing their own copy when <triiiceratops-viewer> is already defined.
<script
type="module"
src="https://unpkg.com/triiiceratops/dist/triiiceratops-element.js"
></script>Resource hints
The viewer requests its manifest only once the element has loaded and connected, and its first tiles only after that. Resource hints in the page <head> start that work earlier:
<link
rel="preload"
as="fetch"
href="https://example.org/manifest.json"
crossorigin="anonymous"
/>
<link
rel="modulepreload"
href="https://unpkg.com/triiiceratops/dist/triiiceratops-element.js"
/>
<link rel="preconnect" href="https://images.example.org" crossorigin />preloadfetches the manifest in parallel with the element. The browser uses it only when it matches the viewer’s own request: thehrefmust equalmanifest-idexactly, and the credentials mode must agree. The viewer requests the manifest withfetch()in CORS mode with credentials'same-origin', whichcrossorigin="anonymous"matches. Withrequests.withCredentials: trueit sends'include'instead; usecrossorigin="use-credentials". Withrequests.headersconfigured, the viewer’s request differs from the preload and the browser won’t use it; leave the preload out.modulepreloadfetches and compiles the ESM element before the module script that runs it. Point it at the same URL as that script. It does not apply to the IIFE build, which is a classic script.preconnectopens the connection to the IIIF image server before the firstinfo.jsonand tile requests. Those are anonymous CORS fetches, so the hint needscrossoriginto warm the connection they use.
Driving the element directly
Registering the element
import 'triiiceratops/element/register';Registration is a side-effecting import and is idempotent. Custom-element registration is first-wins per page: a second, different Triiiceratops core cannot replace an already-registered tag.
Attributes, properties, and events
Attributes:
manifest-id,canvas-id,theme,content-state. Strings only, plus the two boolean attributesread-content-state-from-urlandaccept-dropped-content-state(presence opts in; see content state).Properties:
manifestJson,themeConfig,config,initialCanvasRegion,plugins,searchProvider. Objects, arrays, and functions cannot be passed as HTML attributes, so assign them to the element. Assignment before the element upgrades is safe: the values are applied when it does.Events:
statechange,canvaschange,manifestchange,choicechange,pluginerror,viewererror, andviewerstateavailable. All bubble and are composed, so they escape the shadow root. The two failure channels are also readable as the callback propertiesonviewererrorandonpluginerror, for a host that would rather assign one handler than add a listener.
import 'triiiceratops/element/register';
import type { TriiiceratopsViewerElement } from 'triiiceratops';
// The published element type declares the state bridge (`viewerState`) and
// `searchProvider`. TypeScript hosts widen it locally with the other
// property-tier inputs they assign; those exist on the element at runtime.
type ViewerHost = TriiiceratopsViewerElement & {
manifestJson?: string | object;
themeConfig?: string | object;
config?: string | object;
initialCanvasRegion?: string | object;
plugins?: readonly unknown[];
};
const el = document.querySelector<ViewerHost>('triiiceratops-viewer')!;
el.setAttribute('manifest-id', 'https://example.org/manifest.json');
el.config = { toolbar: { side: 'right' } };
el.addEventListener('canvaschange', (event) => {
const snapshot = (event as CustomEvent).detail;
console.log('canvas is now', snapshot.canvasId);
});The state bridge
The element exposes the owning viewer's live per-instance ViewerState, the same object plugins and framework wrappers read, through a getter-only viewerState property, paired with a viewerstateavailable event:
import 'triiiceratops/element/register';
import type { TriiiceratopsViewerElement } from 'triiiceratops';
const el = document.querySelector<TriiiceratopsViewerElement>(
'triiiceratops-viewer',
)!;
function bind(state: NonNullable<TriiiceratopsViewerElement['viewerState']>) {
// Read on demand…
console.log(state.canvasId);
// …command it…
state.nextCanvas();
// …or subscribe. Notifications are batched and carry no payload: they mean
// "state changed — read what you need".
return state.subscribe(() => console.log('now at', state.canvasId));
}
// Listen THEN check: this catches state that becomes available before, during,
// or after this code runs, with no race and no polling.
el.addEventListener('viewerstateavailable', (event) => {
bind((event as CustomEvent).detail);
});
if (el.viewerState) bind(el.viewerState);Notes on the bridge:
viewerStateisundefinedbefore the inner viewer mounts and again after disconnection, and it is getter-only, so a host cannot replace it.viewerstateavailablemeans only that state can be bound. It does not mean a manifest has loaded, the renderer is ready, or a requested canvas is visible.Ordinary state updates do not repeat the event. A disconnection that destroys the inner viewer and a later reconnection produce a new
ViewerStateand a new event; rebind and drop the old one.For memoized, equality-gated reads over this state,
triiiceratops/selectorsexports the same framework-neutral selector runtime the wrappers and the plugin SDK use.
searchProvider on the element
searchProvider is a property-only input with no reflected attribute. It works as it does in the framework wrappers and in Svelte:
import 'triiiceratops/element/register';
import type { SearchProvider, TriiiceratopsViewerElement } from 'triiiceratops';
const el = document.querySelector<TriiiceratopsViewerElement>(
'triiiceratops-viewer',
)!;
const searchProvider: SearchProvider = async (query, context) => [
{
canvasIndex: 0,
canvasLabel: context.canvasId ?? 'Page 1',
hits: [{ type: 'hit', match: query }],
},
];
el.searchProvider = searchProvider;Set it to null (or leave it unset) to use the viewer's normal IIIF Content Search service discovery.
Hand-wiring a component framework
The two recipes below are only for hosts that want the raw element, most often an existing integration mid-migration. New React and Vue code should use the React and Vue wrappers, which do all of this for you and are the supported path.
Hand-wiring React
import { useEffect, useRef } from 'react';
import 'triiiceratops/element/register';
import { ImageManipulationPlugin } from '@triiiceratops/plugin-image-manipulation';
export function Viewer({ manifestId }) {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.manifestId = manifestId;
el.plugins = [ImageManipulationPlugin];
el.config = { toolbar: { side: 'right' } };
const onStateChange = (e) => console.log('viewer state', e.detail);
el.addEventListener('statechange', onStateChange);
return () => el.removeEventListener('statechange', onStateChange);
}, [manifestId]);
return (
<triiiceratops-viewer
ref={ref}
style={{ display: 'block', width: '100%', height: '600px' }}
/>
);
}TypeScript hosts taking this path must declare the tag in JSX.IntrinsicElements (or set properties through a typed ref, as above) to satisfy the compiler. The wrapper removes that requirement.
Hand-wiring Vue
Tell the compiler the tag is a custom element so it does not try to resolve it as a Vue component, then set complex values as properties through a template ref:
// vite.config.ts
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => tag === 'triiiceratops-viewer',
},
},
});<script setup>
import { onMounted, ref, watch } from 'vue';
import 'triiiceratops/element/register';
import { ImageManipulationPlugin } from '@triiiceratops/plugin-image-manipulation';
const props = defineProps(['manifestId']);
const viewer = ref(null);
function sync() {
viewer.value.manifestId = props.manifestId;
viewer.value.plugins = [ImageManipulationPlugin];
viewer.value.config = { toolbar: { side: 'right' } };
}
onMounted(() => {
sync();
viewer.value.addEventListener('statechange', (e) => {
console.log('viewer state', e.detail);
});
});
watch(() => props.manifestId, sync);
</script>
<template>
<triiiceratops-viewer ref="viewer" style="display: block; width: 100%; height: 600px" />
</template>The wrapper removes the isCustomElement configuration, the property assignments, and the manual listeners.
Live examples
Two example pages are published beside these docs and run this release's bundles:
Plain HTML: one script tag, one element, no bundler and no plugins.
Web component: the same element plus
@triiiceratops/plugin-av, loaded from a second script tag in the required order and registered per viewer.
Verified against the packed package
CI runs packed-consumer fixtures in Chromium for both element forms on this page: wc-esm (bundler ESM registration) and plain-html-iife plus plugin-image-manip-iife (script tags, core and a plugin IIFE loaded in both script orders, which @triiiceratops/plugin-image-manipulation supports because it bundles its own Svelte runtime; @triiiceratops/plugin-av does not and must load after core; see script order). Every code sample on this site is also type-checked against those same tarballs.