Triiiceratops Any framework

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 triiiceratops

No-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 />
  • preload fetches the manifest in parallel with the element. The browser uses it only when it matches the viewer’s own request: the href must equal manifest-id exactly, and the credentials mode must agree. The viewer requests the manifest with fetch() in CORS mode with credentials 'same-origin', which crossorigin="anonymous" matches. With requests.withCredentials: true it sends 'include' instead; use crossorigin="use-credentials". With requests.headers configured, the viewer’s request differs from the preload and the browser won’t use it; leave the preload out.

  • modulepreload fetches 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.

  • preconnect opens the connection to the IIIF image server before the first info.json and tile requests. Those are anonymous CORS fetches, so the hint needs crossorigin to 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 attributes read-content-state-from-url and accept-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, and viewerstateavailable. All bubble and are composed, so they escape the shadow root. The two failure channels are also readable as the callback properties onviewererror and onpluginerror, 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:

  • viewerState is undefined before the inner viewer mounts and again after disconnection, and it is getter-only, so a host cannot replace it.

  • viewerstateavailable means 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 ViewerState and a new event; rebind and drop the old one.

  • For memoized, equality-gated reads over this state, triiiceratops/selectors exports 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.