React
Use Triiiceratops in React 19: a typed component that hosts the viewer's custom element and maps its lifecycle, props and events to React idioms.
triiiceratops/react is a framework wrapper: a real React 19 component that hosts the Triiiceratops custom element and translates its lifecycle, properties, events, and viewer state into React idioms. It does not implement a second viewer. There is one viewer implementation, behind the custom-element boundary.
Svelte stays behind that boundary. You do not install Svelte, add a Svelte plugin to Vite, declare a custom-element tag, register anything, or write useEffect blocks that assign properties to a ref. triiiceratops/react is precompiled JavaScript plus declarations that resolve with no svelte package installed.
Install
pnpm add triiiceratopsnpm install triiiceratopsbun add triiiceratopsReact 19 is an optional peer dependency. Installing Triiiceratops for a different integration never pulls React in, and React is never bundled into the package.
Your first viewer
import { TriiiceratopsViewer } from 'triiiceratops/react';
export function Reader() {
return (
<TriiiceratopsViewer
manifestId="https://example.org/manifest.json"
style={{ display: 'block', height: '600px' }}
/>
);
}That is the whole integration. A few things to know:
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, so it runs once however many viewers mount. There is no side-effect import to remember and noelement/registerin a React app.The wrapper renders one element: the
<triiiceratops-viewer>host. No layout<div>wraps it, so adopting the wrapper changes no sizing or CSS. Give the host a height, as above.It takes no children.
childrenis typednever; the viewer's chrome is not composable from React elements. See what the wrapper does not do.Styles are bundled inside the element's shadow root. There is no stylesheet to import.
Pass the handle, not the state
Everything that reads or commands the viewer goes through a handle. Create it with useViewerHandle(), pass it to the component's handle prop, and read through it:
import {
TriiiceratopsViewer,
useViewer,
useViewerHandle,
useViewerSelector,
} from 'triiiceratops/react';
export function Reader() {
const handle = useViewerHandle();
// Reactive read: re-renders only when the selected value changes.
const canvasId = useViewerSelector(handle, (state) => state.canvasId);
// The live state object: commands and on-demand reads, no subscription.
const viewer = useViewer(handle);
return (
<div className="reader">
<p>{canvasId ?? 'No canvas yet'}</p>
<button type="button" onClick={() => viewer?.nextCanvas()}>
Next canvas
</button>
<TriiiceratopsViewer
handle={handle}
manifestId="https://example.org/manifest.json"
style={{ display: 'block', height: '600px' }}
/>
</div>
);
}If you already reach for useForm-style APIs, the shape is familiar: the hook returns a stable box, the component fills it in, and helpers take the box.
The handle is a stable box, not the state. useViewerHandle() returns the same object for the component's whole lifetime. What changes is the value inside it: null until the element publishes its viewer state, a new binding after a detach/reattach, null again on unmount. That is why the box is what you pass around.
Reads are undefined until the viewer's state exists. The wrapper does not delay rendering to guarantee a non-null value; undefined is the actual state between "React mounted the host" and "the custom element finished mounting its viewer". Handle it the way you handle any other asynchronous resource: the ?? and the optional call above are the whole pattern.
Your UI can live anywhere. The examples put controls before the viewer, but nothing about the wrapper constrains your markup: the handle is a value, so controls can be siblings, ancestors, nested inside your own layout boxes, or in a different part of the tree.
The handle is optional. A viewer that nothing reads from needs no handle. The first example above passes none.
One handle per viewer. Passing the same handle to a second <TriiiceratopsViewer> throws TriiiceratopsHandleConflictError naming both elements, because ambiguous ownership would silently break per-viewer isolation. Two viewers on a page means two useViewerHandle() calls.
useViewer() vs useViewerSelector()
|
| |
|---|---|---|
Returns |
| the projected value, or |
Subscribes to state | No | Yes |
Re-renders 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.
useViewer() returns a typed, readonly view of the same live ViewerState object the element owns. It is not a facade, a Proxy, or a copy. The four lifecycle-plumbing methods (setEventTarget, setViewerElement, destroy, destroyAllPlugins) are hidden so autocomplete offers only supported operations. Identity comparisons hold: the object is reference-equal to handle.get()?.state and to the element's own viewerState.
Reading a notifying member off that object does not subscribe to it. Reading viewer.canvasId during render gives you the value at render time and no re-render when it changes. Use useViewerSelector() for that.
Reactive reads
useViewerSelector() is a generic, memoized projection that re-renders only when its value changes (equality gating). T is inferred from the projection:
import { useViewerSelector } from 'triiiceratops/react';
import type { ViewerHandleSlot } from 'triiiceratops/react';
export function GalleryBadge({ handle }: { handle: ViewerHandleSlot }) {
// `boolean | undefined`, inferred.
const open = useViewerSelector(handle, (state) => state.showThumbnailGallery);
return <span>{open ? 'Gallery open' : 'Gallery closed'}</span>;
}Equality gating
A projection is memoized twice over. Within one notification it is computed once and repeated reads return the same reference. That makes an inline projection a valid React external-store snapshot with no extra machinery. Across notifications, the equality gate decides: when a recompute produces a value that satisfies equals, the projection keeps returning the previous reference, so React does not re-render.
equals defaults to Object.is, which is right for the primitives above. A projection that builds a fresh object or array on every run is never Object.is-equal to its predecessor, so it would re-render on every viewer notification. Give those an equals:
import { useViewerSelector } from 'triiiceratops/react';
import type { ViewerHandleSlot } from 'triiiceratops/react';
export function CanvasCounter({ handle }: { handle: ViewerHandleSlot }) {
const position = useViewerSelector(
handle,
(state) => ({
index: state.currentCanvasIndex,
total: state.canvases.length,
}),
{ equals: (a, b) => a.index === b.index && a.total === b.total },
);
if (!position) return null;
return (
<span>
{position.index + 1} / {position.total}
</span>
);
}No useCallback or useMemo is needed on either the projection or the equality function. Inline arrows whose closures change between renders read current values, which is what makes the helper idiomatic under React 19 and the React Compiler. Reads are correct under concurrent rendering and Strict Mode: the helper is built on React's own useSyncExternalStore, and it never mutates a shared projection during a render pass.
All selectors for one viewer share one underlying subscription. Adding selector components does not multiply ViewerState.subscribe registrations.
Projection failures reach your error boundary. If a projection or equality function throws, the failure is retained and rethrown from your component's own read. It is never swallowed, never converted into a viewererror or pluginerror, and never served as a stale selected value.
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 React it is a single option:
import { useViewerSelector } from 'triiiceratops/react';
import type { ViewerHandleSlot } from 'triiiceratops/react';
export function ZoomReadout({ handle }: { handle: ViewerHandleSlot }) {
const zoom = useViewerSelector(
handle,
(state) => state.viewportScale,
{ cadence: 'frame' },
);
if (zoom === undefined) return null;
return <span>{Math.round(zoom * 100)}%</span>;
}Reading a query-only viewport value (viewportScale, viewportCentre, viewportBounds) at the default state cadence is the one selector mistake that 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
Passing the handle through every intermediate component is tedious. <ViewerProvider value={handle}> publishes it to a subtree, and both hooks have a context form that omits the handle argument:
import {
TriiiceratopsViewer,
useViewer,
useViewerHandle,
useViewerSelector,
ViewerProvider,
} from 'triiiceratops/react';
function CanvasLabel() {
const canvasId = useViewerSelector((state) => state.canvasId);
return <p>{canvasId ?? 'No canvas yet'}</p>;
}
function ZoomButtons() {
const viewer = useViewer();
return (
<>
<button type="button" onClick={() => viewer?.zoomOut()}>
−
</button>
<button type="button" onClick={() => viewer?.zoomIn()}>
+
</button>
</>
);
}
export function Reader() {
const handle = useViewerHandle();
return (
<ViewerProvider value={handle}>
<header>
<CanvasLabel />
<ZoomButtons />
</header>
<TriiiceratopsViewer
handle={handle}
manifestId="https://example.org/manifest.json"
style={{ display: 'block', height: '600px' }}
/>
</ViewerProvider>
);
}<ViewerProvider> is a plain value provider using ordinary React context. It gates nothing, renders its children unconditionally, and has no fallback; reads through the handle stay nullable. Nest a second provider to scope a second viewer, and the nearest one wins.
Calling a hook with neither an explicit handle nor a provider above it throws immediately with a message naming the fix, rather than returning undefined forever.
Props
Every viewer input is a typed prop. Each prop reaches the element either as an HTML attribute or as a JavaScript property (see the Carried as column). You never manage this, but it explains the server-rendering behavior below.
Prop | Type | Carried as |
|---|---|---|
|
| attribute ( |
|
| attribute ( |
|
| attribute ( |
|
| attribute ( |
|
| attribute ( |
|
| attribute ( |
|
| property |
|
| property |
|
| property |
|
| property |
|
| property |
|
| property |
Everything else you pass (className, style, id, data-*, aria-*, ordinary DOM attributes and DOM event handlers) is forwarded to the host element by React, unchanged. Object- and function-valued inputs always reach the element as JavaScript properties, never stringified into attributes, whether or not the lazy registration has finished.
Attribute and property props are both 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.
manifestId and canvasId are uncontrolled
They are one-way instructions to the viewer, not continuously enforced bindings. They behave like defaultValue + onChange, not value + onChange. After the user navigates internally, re-asserting the same canvasId writes nothing, so the wrapper never overrides the user's navigation. There is no controlled mode.
To follow where the viewer is, observe it with a selector, or with the onCanvasChange / onManifestChange callbacks when you want to sync something outside React state (a URL, say):
import { useState } from 'react';
import { TriiiceratopsViewer } from 'triiiceratops/react';
export function Reader({ initialCanvasId }: { initialCanvasId: string }) {
const [currentCanvasId, setCurrentCanvasId] = useState(initialCanvasId);
return (
<>
<p>Showing {currentCanvasId}</p>
<TriiiceratopsViewer
manifestId="https://example.org/manifest.json"
// An instruction, not a binding: this value is applied when it
// changes, and internal navigation is never overwritten by it.
canvasId={initialCanvasId}
onCanvasChange={(snapshot) => {
if (snapshot.canvasId) setCurrentCanvasId(snapshot.canvasId);
}}
style={{ display: 'block', height: '600px' }}
/>
</>
);
}Keep object props stable
Property props are compared with a one-level shallow check: 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, because it would make write suppression depend on the shape of your data.
So a nested object rebuilt on every render is written on every render, and writing config or manifestJson is not free. Hoist it, or memoize it:
import { useMemo } from 'react';
import { TriiiceratopsViewer } from 'triiiceratops/react';
import type { ThemeConfig, ViewerConfig } from 'triiiceratops/react';
// Hoisted: one object for the module's lifetime.
const THEME_CONFIG: ThemeConfig = { panelBg: '#101014' };
export function Reader({ side }: { side: 'left' | 'right' }) {
// Memoized: a new object only when what it depends on changes.
const config = useMemo<ViewerConfig>(() => ({ toolbar: { side } }), [side]);
return (
<TriiiceratopsViewer
manifestId="https://example.org/manifest.json"
config={config}
themeConfig={THEME_CONFIG}
style={{ display: 'block', height: '600px' }}
/>
);
}With config: { debug: true } the wrapper warns once, naming the prop, after one property input has been reassigned more than 10 times on one element, so you can find an unmemoized object prop.
Plugins
plugins takes framework-neutral SDK plugins: readonly SdkPlugin[], with no Svelte types or Svelte runtime involved. The plugins guide has the React example.
Activation lifetime is keyed to plugin identity, not to the identity of the list: re-supplying an equal list leaves running plugins untouched. They are not torn down, restarted, or given re-injected styles. Hoist the list anyway, since a fresh createPdfExportPlugin() call per render would produce a different plugin each time.
Events
The custom element's channels are typed callback props. Each receives the event detail directly, never a CustomEvent, so your code is independent of the DOM event envelope.
Prop | Payload |
|---|---|
|
|
|
|
|
|
|
|
| the exact |
| the exact typed |
import { TriiiceratopsViewer } from 'triiiceratops/react';
export function Reader() {
return (
<TriiiceratopsViewer
manifestId="https://example.org/manifest.json"
onCanvasChange={(snapshot) => history.replaceState(
null,
'',
`?canvas=${encodeURIComponent(snapshot.canvasId ?? '')}`,
)}
// The original PluginError object, recovery behavior intact.
onPluginError={(error) => error.retry()}
onViewerError={(error) => console.error(error.message)}
style={{ display: 'block', height: '600px' }}
/>
);
}Changing a callback prop between renders neither leaks nor duplicates a DOM listener: one listener per channel is installed for the element's lifetime and always calls the current callback.
Custom search
The searchProvider 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 React tab in custom search providers.
Imperative access
When a hook is unsuitable (a test, or an integration with imperative code), a forwarded ref yields the same two-member ViewerHandle the handle publishes:
import { useRef } from 'react';
import { TriiiceratopsViewer } from 'triiiceratops/react';
import type { TriiiceratopsViewerRef } from 'triiiceratops/react';
export function Reader() {
const ref = useRef<TriiiceratopsViewerRef | null>(null);
return (
<>
<button
type="button"
onClick={() => ref.current?.element.scrollIntoView()}
>
Scroll to viewer
</button>
<TriiiceratopsViewer
ref={ref}
manifestId="https://example.org/manifest.json"
style={{ display: 'block', height: '600px' }}
/>
</>
);
}ref.current is null until the viewer publishes its state and again after unmount. It has two members: element (the <triiiceratops-viewer> host) and state (the readonly viewer state). ref.current.state is reference-equal to what useViewer() returns.
Server rendering
Importing triiiceratops/react 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 props (manifest-id, canvas-id, theme, content-state, read-content-state-from-url) and your forwarded host attributes, and nothing else. It has no shadow-DOM internals, property 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.
Triiiceratops ships no meta-framework-specific components; there is no Next.js package or adapter. The wrapper is a plain React component with SSR-safe evaluation, and you place the client-only boundary yourself.
Wiring errors
The errors below are thrown so they reach a React error boundary rather than the console.
Error | Means |
|---|---|
| one handle was passed to two viewers |
|
|
| two different Triiiceratops core versions loaded on one page |
| no |
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.
Three further failure modes are silent by default and surface only under config: { debug: true }: an unmemoized property prop, a handle created and never passed to a viewer, and a state-cadence projection reading through a query-only viewport value. 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 real selector runtime that useViewerSelector() uses. It needs no DOM viewer, custom element, renderer surface, or network. Run it under jsdom or happy-dom (most React test setups already use one), because the published entry bundles a fetch polyfill that requires a self global. Bare Node fails with ReferenceError: self is not defined. To run in bare Node, set globalThis.self = globalThis first.
React consumers pass the test handle straight in wherever a useViewerHandle() slot would go, with no wrapper and no .get():
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, expect, test } from 'vitest';
import { createTestViewerHandle, flush } from 'triiiceratops/testing';
import type { TestViewerHandle } from 'triiiceratops/testing';
import { useViewerSelector } from 'triiiceratops/react';
// React logs "The current testing environment is not configured to support
// act(...)" unless you set this. Most React setups put it in a shared test
// setup file; Testing Library sets it for you.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
function CanvasLabel({ handle }: { handle: TestViewerHandle }) {
const canvasId = useViewerSelector(handle, (state) => state.canvasId);
return <p>{canvasId ?? 'No canvas yet'}</p>;
}
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();
const container = document.createElement('div');
const root = createRoot(container);
await act(async () => root.render(<CanvasLabel handle={handle!} />));
expect(container.textContent).toBe('No canvas yet');
handle.state.setCanvas('https://example.org/canvas/2');
// Notifications are batched: settle the flush before asserting.
await act(async () => flush());
expect(container.textContent).toBe('https://example.org/canvas/2');
// Unmounting is a React update too, so it belongs inside `act` as well.
await act(async () => root.unmount());
});Swap react-dom/client for your own render helper or Testing Library if you have one. What matters is that the handle is real state, and that a command plus await flush() is how you drive it.
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
This wrapper provides idiomatic access to the viewer. It has two limits, and both apply to every host, not only React:
Styling stops at theme tokens.
theme,themeConfig, and the--tri-*custom properties are the whole surface; the shadow-DOM internals are not reachable. Everything about the host element (className,style, layout, size, borders) is yours as usual. See theming.Viewer chrome is not composable. You cannot supply React components for the toolbar, panels, or navigation, and the component accepts no children or slot content. Building your own chrome covers the three supported alternatives, with a React custom-toolbar example.
Direct custom-element integration is also supported. See driving the element directly. Adopting the wrapper is not required.
There is no React example page: the wrapper only adds React 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.