Triiiceratops Vue

Vue

Use Triiiceratops in Vue 3.5: a typed component that hosts the viewer's custom element and maps its lifecycle, props and events to Vue idioms.

triiiceratops/vue is a framework wrapper: a real Vue 3.5 component that hosts the Triiiceratops custom element and translates its lifecycle, properties, events, and viewer state into Vue idioms. It is not a second viewer. The one viewer implementation sits behind the custom-element boundary.

Svelte stays behind that boundary. You do not install Svelte, add a Svelte plugin to Vite, or write compilerOptions.isCustomElement anywhere: <TriiiceratopsViewer> is a render-function component, so the raw <triiiceratops-viewer> tag never reaches Vue's template compiler. triiiceratops/vue is precompiled JavaScript plus declarations that resolve with no svelte package installed.

Install

pnpm add triiiceratops

Vue 3.5 is an optional peer dependency: installing Triiiceratops for a different integration never pulls Vue in, and Vue is never bundled into the package.

Your first viewer

<script setup lang="ts">
import { TriiiceratopsViewer } from 'triiiceratops/vue';
</script>

<template>
    <TriiiceratopsViewer
        manifest-id="https://example.org/manifest.json"
        style="display: block; height: 600px"
    />
</template>

That is the whole integration. It needs no vite.config.ts change, registration import, or onMounted block assigning properties to a ref. Worth knowing:

  • Registration is automatic. The first <TriiiceratopsViewer> to mount in the browser lazily imports and registers the self-contained custom element. The work is memoized and shared across every instance on the page.

  • The wrapper renders one element: the <triiiceratops-viewer> host. There is no layout wrapper, so adopting it changes no sizing or CSS. Give the host a height, as above.

  • It has no slots. The viewer's chrome is not composable from Vue components. See what the wrapper does not do.

  • class, style, id, data-*, and ordinary attributes fall through to the host element. inheritAttrs is disabled and the render function spreads attrs explicitly, so attribute inheritance stays predictable even though the component renders a single element.

  • Styles are bundled inside the element's shadow root. There is no stylesheet to import.

The template ref is the handle

A viewer handle only needs to be a stable, consumer-owned box that the component fills in. Vue already has one, so this wrapper adds no handle API of its own. An ordinary template ref is the handle:

<script setup lang="ts">
import { useTemplateRef } from 'vue';
import {
    TriiiceratopsViewer,
    useViewerSelector,
    type TriiiceratopsViewerInstance,
} from 'triiiceratops/vue';

const viewer = useTemplateRef<TriiiceratopsViewerInstance>('viewer');

// Reactive read: a `computed` that updates when the selected value changes.
const canvasId = useViewerSelector(viewer, (state) => state.canvasId);

// Imperative command, straight through the ref.
function next(): void {
    viewer.value?.state?.nextCanvas();
}
</script>

<template>
    <p>{{ canvasId ?? 'No canvas yet' }}</p>
    <button type="button" @click="next">Next canvas</button>
    <TriiiceratopsViewer
        ref="viewer"
        manifest-id="https://example.org/manifest.json"
        style="display: block; height: 600px"
    />
</template>

If you already use useTemplateRef and VueUse composables, nothing here is new: the ref is the handle and the composables take it.

The ref is optional. A viewer that nothing reads from needs no ref, like the first example above.

Your UI can live anywhere. The ref is a value, so controls can sit anywhere in the tree, inside your own layout components or not. The wrapper imposes no markup structure.

One ref per viewer. Two viewers on a page means two template refs; their state, selectors, commands, emits, and handles are isolated. Putting the same ref on a second <TriiiceratopsViewer> throws TriiiceratopsHandleConflictError naming both elements, because ambiguous ownership would silently make every read follow whichever viewer mounted last. Two cases are exempt because the sharing is intended: a ref inside v-for (Vue collects every match into an array) and a callback ref (:ref="(el) => …", where you decide what to do with each value).

useViewer() vs useViewerSelector()

useViewer(handle)

useViewerSelector(handle, projection, options?)

Returns

ComputedRef<ReadonlyViewerState | undefined>

ComputedRef<T | undefined>

Subscribes to state

No

Yes

Invalidates when

the viewer binds, rebinds, or unmounts

the selected value changes

Use it for

commands, one-shot reads inside handlers

anything you render

Both also have a context form that omits the handle. See passing the handle to a deep tree.

Both are a computed, so both unwrap automatically in templates. useViewer() gives you the same live ViewerState object the element owns (not a facade, Proxy, or copy), with the four lifecycle methods (setEventTarget, setViewerElement, destroy, destroyAllPlugins) hidden so autocomplete offers only supported operations. It is reference-equal to viewer.value?.state.

Reading a notifying member off that object does not subscribe to it:

<script setup lang="ts">
import { useTemplateRef } from 'vue';
import {
    TriiiceratopsViewer,
    useViewer,
    type TriiiceratopsViewerInstance,
} from 'triiiceratops/vue';

const viewer = useTemplateRef<TriiiceratopsViewerInstance>('viewer');
const state = useViewer(viewer);

// Commands: correct — read at the moment the user clicks.
const zoomIn = (): void => state.value?.zoomIn();
const search = (query: string): void => void state.value?.search(query);
</script>

<template>
    <button type="button" @click="zoomIn">Zoom in</button>
    <button type="button" @click="search('lorem')">Search</button>
    <TriiiceratopsViewer
        ref="viewer"
        manifest-id="https://example.org/manifest.json"
        style="display: block; height: 600px"
    />
</template>

Rendering state.canvasId from useViewer() would show the value at binding time and never update. Use useViewerSelector() for anything you render.

Reactive reads

useViewerSelector() is one generic projection, memoized and gated by equality. T is inferred from the projection:

<script setup lang="ts">
import { useTemplateRef } from 'vue';
import {
    TriiiceratopsViewer,
    useViewerSelector,
    type TriiiceratopsViewerInstance,
} from 'triiiceratops/vue';

const viewer = useTemplateRef<TriiiceratopsViewerInstance>('viewer');
// ComputedRef<boolean | undefined>, inferred.
const galleryOpen = useViewerSelector(
    viewer,
    (state) => state.showThumbnailGallery,
);
</script>

<template>
    <span>{{ galleryOpen ? 'Gallery open' : 'Gallery closed' }}</span>
    <TriiiceratopsViewer
        ref="viewer"
        manifest-id="https://example.org/manifest.json"
        style="display: block; height: 600px"
    />
</template>

Equality gating

A projection is memoized twice over. Within one viewer notification it is computed once, and across notifications the equality gate decides: when a recompute produces a value that satisfies equals, the projection keeps returning the previous reference, so the computed stays clean and nothing downstream re-renders.

equals defaults to Object.is. A projection that builds a fresh object or array on every run is never Object.is-equal to its predecessor, so give those an equals:

<script setup lang="ts">
import { useTemplateRef } from 'vue';
import {
    TriiiceratopsViewer,
    useViewerSelector,
    type TriiiceratopsViewerInstance,
} from 'triiiceratops/vue';

const viewer = useTemplateRef<TriiiceratopsViewerInstance>('viewer');
const position = useViewerSelector(
    viewer,
    (state) => ({
        index: state.currentCanvasIndex,
        total: state.canvases.length,
    }),
    { equals: (a, b) => a.index === b.index && a.total === b.total },
);
</script>

<template>
    <span v-if="position">{{ position.index + 1 }} / {{ position.total }}</span>
    <TriiiceratopsViewer
        ref="viewer"
        manifest-id="https://example.org/manifest.json"
        style="display: block; height: 600px"
    />
</template>

All selectors for one viewer share one underlying subscription. Adding selector components does not multiply ViewerState.subscribe registrations.

Projection failures reach Vue's own error handling. A projection or equality function that throws does so during your component's evaluation, so it reaches onErrorCaptured and app.config.errorHandler. It is never swallowed, never converted into a viewer-error or plugin-error emit, and never served as a stale selected value.

Vue reactive dependencies are tracked

Because the projection runs inside a computed, any ref or reactive value it reads is tracked automatically. You never add a manual watcher to keep a projection current with your own state:

<script setup lang="ts">
import { ref, useTemplateRef } from 'vue';
import {
    TriiiceratopsViewer,
    useViewerSelector,
    type TriiiceratopsViewerInstance,
} from 'triiiceratops/vue';

const viewer = useTemplateRef<TriiiceratopsViewerInstance>('viewer');
const showIndex = ref(false);

// Two dependencies, one composable: the viewer's canvas AND `showIndex`.
// Toggling `showIndex` invalidates the selection with no watcher of your own.
const label = useViewerSelector(viewer, (state) =>
    showIndex.value
        ? `Canvas ${state.currentCanvasIndex + 1}`
        : (state.canvasId ?? 'No canvas yet'),
);
</script>

<template>
    <label>
        <input v-model="showIndex" type="checkbox" />
        Show index
    </label>
    <p>{{ label }}</p>
    <TriiiceratopsViewer
        ref="viewer"
        manifest-id="https://example.org/manifest.json"
        style="display: block; height: 600px"
    />
</template>

Selector cadence

The cadence option chooses which notification wakes a projection: the default state for anything in the viewer's state inventory, and frame for the query-only viewport values (scale, center, bounds, container size) that the renderer answers per frame and that are not mirrored into viewer state. Selector cadence explains the split and why it exists. In Vue it is one option:

<script setup lang="ts">
import { useTemplateRef } from 'vue';
import {
    TriiiceratopsViewer,
    useViewerSelector,
    type TriiiceratopsViewerInstance,
} from 'triiiceratops/vue';

const viewer = useTemplateRef<TriiiceratopsViewerInstance>('viewer');
const zoom = useViewerSelector(
    viewer,
    (state) => state.viewportScale,
    { cadence: 'frame' },
);
</script>

<template>
    <span v-if="zoom !== undefined">{{ Math.round(zoom * 100) }}%</span>
    <TriiiceratopsViewer
        ref="viewer"
        manifest-id="https://example.org/manifest.json"
        style="display: block; height: 600px"
    />
</template>

Reading a query-only viewport value (viewportScale, viewportCentre, viewportBounds) at the default state cadence fails silently: the projection appears frozen. See what notifies for the inventory that decides which members a state-cadence projection may read.

Passing the handle to a deep tree

provideViewer(viewer) publishes the handle to the whole subtree, and both composables have a context form that omits the handle argument:

<script setup lang="ts">
import { useTemplateRef } from 'vue';
import {
    provideViewer,
    TriiiceratopsViewer,
    type TriiiceratopsViewerInstance,
} from 'triiiceratops/vue';
import CanvasLabel from './CanvasLabel.vue';

const viewer = useTemplateRef<TriiiceratopsViewerInstance>('viewer');
provideViewer(viewer);
</script>

<template>
    <header><CanvasLabel /></header>
    <TriiiceratopsViewer
        ref="viewer"
        manifest-id="https://example.org/manifest.json"
        style="display: block; height: 600px"
    />
</template>
<!-- CanvasLabel.vue -->
<script setup lang="ts">
import { useViewer, useViewerSelector } from 'triiiceratops/vue';

const canvasId = useViewerSelector((state) => state.canvasId);
const state = useViewer();
</script>

<template>
    <p>{{ canvasId ?? 'No canvas yet' }}</p>
    <button type="button" @click="state?.nextCanvas()">Next</button>
</template>

provideViewer gates nothing and has no fallback; reads through the handle stay nullable. Providing again in a nested component scopes a second viewer, and the nearest one wins.

A <ViewerProvider :value="viewer"> component is also exported, for parity with triiiceratops/react and for teams who prefer the boundary visible in the template. Vue does not need it: provideViewer in setup is enough.

Calling a composable with neither an explicit handle nor a provided one throws immediately with a message naming the fix, rather than returning undefined forever.

Props

Every viewer input is a typed prop, usable with Vue's normal kebab-case in templates.

Prop

Type

Carried as

manifest-id

string

attribute (manifest-id)

canvas-id

string

attribute (canvas-id)

theme

string

attribute (theme)

content-state

string

attribute (content-state)

read-content-state-from-url

boolean

attribute (read-content-state-from-url)

accept-dropped-content-state

boolean

attribute (accept-dropped-content-state)

manifest-json

string | Record<string, any>

property

theme-config

string | ThemeConfig

property

config

string | ViewerConfig

property

initial-canvas-region

string | CanvasRegion

property

plugins

readonly SdkPlugin[]

property

search-provider

SearchProvider | null

property

Anything not in that table is not a viewer input: it lands in attrs and is forwarded to the host element untouched. Object- and function-valued inputs always reach the element as JavaScript properties, never stringified into attributes, whether or not the lazy registration has finished.

Both tiers in the table above (attribute and property) are edge-triggered: a write happens only when the prop value differs from the value the wrapper last applied. A parent re-render with equal props writes nothing, so it never reloads your manifest, snaps the viewport, or restarts your plugins.

manifest-id and canvas-id are uncontrolled

They are one-way instructions to the viewer, not continuously enforced bindings. There is no v-model for them: after the user navigates internally, re-asserting the same canvas-id writes nothing, so the wrapper never overrides the user's navigation.

To follow where the viewer actually is, observe it with a selector, or with the @canvas-change / @manifest-change emits when you want to sync something outside Vue state (a router, say):

<script setup lang="ts">
import { ref } from 'vue';
import {
    TriiiceratopsViewer,
    type ViewerStateSnapshot,
} from 'triiiceratops/vue';

const initialCanvasId = 'https://example.org/canvas/1';
const currentCanvasId = ref(initialCanvasId);

function onCanvasChange(snapshot: ViewerStateSnapshot): void {
    if (snapshot.canvasId) currentCanvasId.value = snapshot.canvasId;
}
</script>

<template>
    <p>Showing {{ currentCanvasId }}</p>
    <TriiiceratopsViewer
        manifest-id="https://example.org/manifest.json"
        :canvas-id="initialCanvasId"
        style="display: block; height: 600px"
        @canvas-change="onCanvasChange"
    />
</template>

Use shallowRef for object props

Property-tier inputs are compared by one-level shallow equality: identical by Object.is; or both arrays of equal length with identical elements; or both plain objects with equal own-key sets and identical values. Deep equality is not used.

Two consequences in Vue:

  • A nested object rebuilt on every render is written on every render, and writing config or manifest-json is not free. Hoist it or keep it in a ref.

  • Use shallowRef, not ref, for object-valued viewer inputs. A deep ref hands the wrapper a reactive proxy rather than your object, so identity no longer matches what you passed.

<script setup lang="ts">
import { shallowRef } from 'vue';
import { TriiiceratopsViewer, type ViewerConfig } from 'triiiceratops/vue';

// shallowRef: the wrapper receives this exact object, not a reactive proxy.
const config = shallowRef<ViewerConfig>({ toolbar: { side: 'right' } });

function moveToolbar(side: 'left' | 'right'): void {
    // Replace the object; do not mutate it in place.
    config.value = { toolbar: { side } };
}
</script>

<template>
    <button type="button" @click="moveToolbar('left')">Toolbar left</button>
    <TriiiceratopsViewer
        manifest-id="https://example.org/manifest.json"
        :config="config"
        style="display: block; height: 600px"
    />
</template>

With config: { debug: true } the wrapper warns once, naming the prop, after one property-tier input has been re-assigned more than 10 times on one element, so an accidentally unstable object prop is easy to find.

Plugins

plugins takes framework-neutral SDK plugins: readonly SdkPlugin[], with no Svelte types or runtime involved. The plugins guide has the Vue example.

Activation lifetime is keyed to plugin identity, not to the identity of the list: re-supplying an equal list leaves running plugins untouched: nothing is torn down, restarted, or re-injected. Build the list once outside any reactive re-evaluation anyway, since a fresh createPdfExportPlugin() call produces a different plugin each time.

Emits

The custom element's channels are typed emits, usable with Vue's normal template casing. Each carries the event detail directly, never a CustomEvent, so your code is independent of the DOM event envelope.

Emit

Payload

@state-change

ViewerStateSnapshot (any inventoried change, batched)

@canvas-change

ViewerStateSnapshot

@manifest-change

ViewerStateSnapshot

@choice-change

ViewerStateSnapshot

@plugin-error

the original PluginError, with a callable retry()

@viewer-error

the original typed ViewerError

<script setup lang="ts">
import {
    TriiiceratopsViewer,
    type PluginError,
    type ViewerError,
    type ViewerStateSnapshot,
} from 'triiiceratops/vue';

function syncUrl(snapshot: ViewerStateSnapshot): void {
    history.replaceState(
        null,
        '',
        `?canvas=${encodeURIComponent(snapshot.canvasId ?? '')}`,
    );
}
// The original PluginError object, recovery behavior intact.
const retry = (error: PluginError): void => error.retry();
const report = (error: ViewerError): void => console.error(error.message);
</script>

<template>
    <TriiiceratopsViewer
        manifest-id="https://example.org/manifest.json"
        style="display: block; height: 600px"
        @canvas-change="syncUrl"
        @plugin-error="retry"
        @viewer-error="report"
    />
</template>

The search-provider prop supplies search results from your own application code instead of a manifest-declared IIIF Content Search service. It behaves the same in every host, so it is documented once with a Vue tab in custom search providers.

Imperative access

The same template ref provides imperative access. It exposes two members: element (the <triiiceratops-viewer> host) and state (the readonly viewer state):

<script setup lang="ts">
import { useTemplateRef } from 'vue';
import {
    TriiiceratopsViewer,
    type TriiiceratopsViewerInstance,
} from 'triiiceratops/vue';

const viewer = useTemplateRef<TriiiceratopsViewerInstance>('viewer');

function scrollToViewer(): void {
    viewer.value?.element.scrollIntoView();
}
</script>

<template>
    <button type="button" @click="scrollToViewer">Scroll to viewer</button>
    <TriiiceratopsViewer
        ref="viewer"
        manifest-id="https://example.org/manifest.json"
        style="display: block; height: 600px"
    />
</template>

viewer.value.state is reference-equal to what useViewer() returns and to the element's own viewerState.

Server rendering

Importing triiiceratops/vue is safe where window, document, and customElements do not exist: nothing browser-only runs at module evaluation and nothing is registered.

On the server, <TriiiceratopsViewer> renders an inert host: the <triiiceratops-viewer> tag carrying the attribute-tier inputs (manifest-id, canvas-id, theme, content-state, read-content-state-from-url) and your forwarded host attributes. It has no shadow-DOM internals, property-tier values, or renderer. The client's first render emits the identical attribute set, so hydration reuses and upgrades the same host with no mismatch, and viewer internals initialize only in the browser.

Unlike with the React wrapper, state-reading components server-render harmlessly: there is no viewer during a server render, so the refs from useViewer() and useViewerSelector() hold undefined and your not-ready branch is what gets serialized. Because reads are nullable rather than gated, the client's first render agrees with it and hydration reports no mismatch. Write that branch as markup the client can also produce.

Triiiceratops ships no meta-framework-specific components; there is no Nuxt module. The wrapper is a plain Vue component with SSR-safe evaluation.

<KeepAlive>

<KeepAlive> deactivation detaches the element from the document for long enough that the custom element destroys its inner viewer, and reactivation mounts a brand-new one. This has two consequences:

  • The wrapper rebinds cleanly. On reactivation the element publishes a new ViewerState; the wrapper atomically disposes the previous selector runtime, publishes the new binding, and rebuilds the handle. Every composable rewires itself: selectors, commands, and the template ref all keep working, and no consumer is left holding a projection subscribed to a disposed runtime.

  • Viewer state does not survive. The manifest, canvas, viewport, and plugin state from before deactivation are gone, because the object that held them was destroyed. Restoring it is not attempted: that would require the wrapper to own a second state surface, which it does not have.

With config: { debug: true }, the wrapper warns once when it sees a second ViewerState, because the state loss is otherwise silent.

If a round trip must preserve where the user was, keep that in your own state and re-supply it. For example, remember the last @canvas-change snapshot's canvasId and pass it back as :canvas-id.

Wiring errors

Failures are thrown from a watcher so they reach onErrorCaptured and app.config.errorHandler rather than the console.

Error

Means

TriiiceratopsElementVersionError

<triiiceratops-viewer> is already defined by a constructor with no viewerState getter, usually a second, older Triiiceratops core that registered first

TriiiceratopsCoreConflictError

two different Triiiceratops core versions loaded on one page

TriiiceratopsElementRegistrationError

no customElements registry: the mount path ran outside a browser

TriiiceratopsHandleConflictError

one template ref was put on two viewers

The handle conflict is raised from the second viewer's mount hook, so it reaches onErrorCaptured and app.config.errorHandler like the others. It is the same error, with the same message, that triiiceratops/react raises when one useViewerHandle() slot is passed to two viewers. See One ref per viewer.

Version conflicts are diagnosed synchronously, right after registration: the wrapper probes the constructor that actually owns the tag for the viewerState getter. There is no timeout, deadline, retry, or customElements.whenDefined poll anywhere in the path, so an incompatible page fails fast instead of hanging.

Further failure modes are silent by design and surface only under config: { debug: true }: unstable property-tier props, a state-cadence projection reading a query-only viewport value, and the <KeepAlive> state loss above. See debug diagnostics.

Testing your own components

triiiceratops/testing builds a handle backed by a real ViewerState, with real commands, real batched notifications, and the same selector runtime useViewerSelector() consults. It needs no DOM viewer, custom element, renderer surface, or network. Run it under jsdom or happy-dom, which a Vue test runner already provides. The published entry bundles a fetch polyfill that requires a self global, so bare Node fails with ReferenceError: self is not defined (or set globalThis.self = globalThis first).

Wrap the handle in a shallowRef and pass it where a template ref would go:

import { createApp, defineComponent, h, nextTick, shallowRef } from 'vue';
import { afterEach, expect, test } from 'vitest';
import { createTestViewerHandle, flush } from 'triiiceratops/testing';
import type { TestViewerHandle } from 'triiiceratops/testing';
import { useViewerSelector } from 'triiiceratops/vue';

let handle: TestViewerHandle | undefined;
// `dispose()` drops the underlying subscription. It is idempotent, so an
// already-disposed handle is fine here.
afterEach(() => handle?.dispose());

test('follows the viewer to a new canvas', async () => {
    handle = createTestViewerHandle();
    // shallowRef, NOT ref: a deep ref would hand the composable a reactive
    // proxy of the handle, and identity comparisons would stop holding.
    const viewer = shallowRef(handle);

    const CanvasLabel = defineComponent({
        setup() {
            const canvasId = useViewerSelector(viewer, (s) => s.canvasId);
            return () => h('p', canvasId.value ?? 'No canvas yet');
        },
    });

    const container = document.createElement('div');
    const app = createApp(CanvasLabel);
    app.mount(container);
    expect(container.textContent).toBe('No canvas yet');

    handle.state.setCanvas('https://example.org/canvas/2');
    // Notifications are batched; then let Vue re-render.
    await flush();
    await nextTick();

    expect(container.textContent).toBe('https://example.org/canvas/2');
    app.unmount();
});

createTestViewerHandle() accepts { fixtures } to seed a config, an active locale, or already-parsed manifest JSON (through the real setManifestData command, still with no network). handle.attachRenderer() mounts core's headless renderer stand-in and fires the real readiness path, which is how a cadence: 'frame' projection and the query-only viewport values are exercised headlessly. It returns the stand-in, which is also the controller: setView moves the viewport and emitFrame fires one animation event, synchronously.

What the wrapper does not do

The wrapper gives you idiomatic access to the viewer. Two limits are outside its scope, and both apply to every host:

  • Styling stops at theme tokens. theme, theme-config, and the --tri-* custom properties are the whole surface; the shadow-DOM internals are not reachable. Everything about the host element (class, style, layout, size, borders) is yours as usual, and scoped styles (<style scoped>) reach the host but nothing inside it. See theming.

  • Viewer chrome is not composable. You cannot supply Vue components for the toolbar, panels, or navigation, and the component has no slots. Building your own chrome covers the three supported alternatives, with a Vue custom-toolbar example.

Direct custom-element integration is still fully supported. See driving the element directly. Adopting the wrapper is optional.

There is no Vue example page: the wrapper only adds Vue idioms on top of the same element every host uses. To see that element running this release's bundles in a browser, open the web component example, which loads it from a script tag with no bundler.