Any framework (web component)
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 — no adapter required.
This is your page if you are using Angular, Lit, Solid, Svelte-free vanilla JavaScript, Alpine.js, htmx, Astro, Ember, jQuery, a server-rendered template language (Django, Rails, Laravel, ASP.NET, PHP), a static-site generator, or WordPress — as a block, a shortcode, or a raw HTML widget. Every one of those is the same two steps: get the script onto the page, then set attributes and properties on the tag.
React, Vue, and Svelte have first-class components
Those three have dedicated guides, and you should use them rather than the element directly — each translates props, events, and viewer state into its framework's own idioms:
- React guide —
triiiceratops/react - Vue guide —
triiiceratops/vue - Svelte guide —
triiiceratops/svelte
React and Vue require no Svelte, at runtime or at type-check time. The custom-element material here stays fully supported for hosts that want direct DOM control, including a React or Vue app that deliberately drops down to it — see hand-wiring.
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, events, styles, and content-state behavior. Styles and themes are installed inside the element's shadow root — there is no separate element stylesheet to import.
Install
No-bundler pages skip this — 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 — no install, no build step. It bundles the element, the Svelte runtime, OpenSeadragon, 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 = … — no ref needed
outside a component framework.
- 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.
Runs under a strict CSP without unsafe-eval — see the CSP recipe.
From there: add plugins, configure the UI, or theme it.
Driving the element directly
Registering the element
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. Strings only. - Properties —
manifestJson,themeConfig,config,initialCanvasRegion,plugins,searchProvider. Objects, arrays, and functions cannot travel through an HTML attribute, so assign them to the element. Assignment before the element upgrades is safe: the values are ported when it does. - Events —
statechange,canvaschange,manifestchange,choicechange,pluginerror,viewererror, andviewerstateavailable. All bubble and are composed, so they escape the shadow root.
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 — a host physically cannot replace it.viewerstateavailablemeans only that state can be bound. It does not mean a manifest has loaded, OpenSeadragon 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 — there is no reflected attribute —
and it works exactly 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 exist only for hosts that deliberately 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.
Verified against the packed package
CI runs packed-consumer fixtures across Chromium, Firefox, and WebKit 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). Every code sample on this site is also
type-checked against those same tarballs.