Triiiceratops IIIF Content State

IIIF Content State

Which IIIF Content State forms Triiiceratops resolves, and how to turn an iiif-content parameter into viewer inputs.

A content state is a portable IIIF description of a view: either a bare IIIF URI, or a W3C Annotation with motivation: contentState whose target names a Canvas (optionally with an #xywh= region or a #t= media time) and whose partOf names the Manifest. It describes what to show, not how it was delivered.

The iiif-content request parameter is one delivery channel for it. So are a paste, a drop, a FileReader, and a data-* attribute. By default the host handles delivery: the viewer reads none of these channels itself, because a component that read the address bar could break your routing or consume an iiif-content parameter meant for the page around it, and one that intercepted every drop could take a drag your page meant to handle. Instead, it accepts the payload from you on the content-state input. If you opt in, it will also read that one parameter or accept a drop on your behalf.

You can also parse a content state without the viewer. parseContentState turns a content state into a view target ({ manifestId, canvasId?, region?, time? }), which you map onto whatever inputs you like.

import { parseContentState } from 'triiiceratops';

const param = new URLSearchParams(location.search).get('iiif-content');
const target = param ? parseContentState(param) : null;

// Your own routing wins — the URL parameter is the fallback, not the authority.
if (target) {
    console.log(target.manifestId, target.canvasId, target.region);
}

parseContentState is pure and fetches nothing. A bare URI comes back as the manifestId for you to dereference; it is never dereferenced here. The viewer, given the same URI, does dereference it.

Passing one to the viewer

Three inputs are available on every distribution: the Svelte component, the custom element, and the React and Vue wrappers. The first is the payload; the other two each let the viewer read one ambient channel:

Input

Attribute

Default

contentState

content-state

—

readContentStateFromUrl

read-content-state-from-url

off

acceptDroppedContentState

accept-dropped-content-state

off

content-state takes a content state in any of its forms: a bare IIIF URI, the Annotation as JSON, or that Annotation base64url-encoded as the iiif-content parameter delivers it.

<triiiceratops-viewer content-state="https://example.org/state/1">
</triiiceratops-viewer>

read-content-state-from-url delegates one channel, the iiif-content parameter, to the viewer. It is a boolean attribute: presence opts in. It is off by default, it is read once on mount, and the viewer never writes to the address bar, so URL cleanup and re-navigation in a single-page application remain the host's responsibility.

<triiiceratops-viewer read-content-state-from-url></triiiceratops-viewer>

"Once on mount" is literal: the flag is read on the viewer's first render, so it has to be set before the element is inserted into the document. Adding the attribute to a viewer that is already mounted does nothing, because there is no second read. Build the element with the attribute (or render it with the prop) rather than setting it afterwards.

Accepting a drop

accept-dropped-content-state delegates the other ambient channel: a content state dropped onto the viewer (Cookbook recipe 0599). It is a boolean attribute, presence opts in, and it is off by default for the same reason the URL flag is: a viewer embedded in a page must not intercept drags the page handles itself.

<triiiceratops-viewer accept-dropped-content-state>
</triiiceratops-viewer>

The payload is read from the drag's text/plain flavor and nothing else. That is where the recipe's own drag source puts it. text/uri-list is not read, because a browser fills that flavor with an image's src whenever the drag started from an <img>. Every form the supported-forms table lists arrives this way too: a bare URI, the Annotation as JSON, or its base64url encoding.

Unlike the URL flag, this one is live rather than read once on mount, and a drop is the one delivery that outranks the discrete inputs (see precedence below).

Precedence

When more than one source is present:

a dropped content state
  > manifest-id / manifest-json (+ canvas-id, initial-canvas-region)
  > content-state
  > the iiif-content URL parameter

The discrete inputs are the manual API, so they win. The URL is ambient and lowest-trust. Setting manifest-id therefore turns a content state into a no-op rather than a conflict. That lets an application keep its own routing and still opt into the parameter as a fallback.

Precedence applies per input, not to the content state as a whole: a content state still opens its manifest when you set only canvas-id or only initial-canvas-region, but the canvas and the region you set are the ones honored, and the target's own canvas or #xywh is dropped.

A drop outranks all of it, but only for the manifest. The user dropped the content state on this viewer, so the viewer opens the manifest it names even when the host has set manifest-id. canvas-id and initial-canvas-region still win over the canvas and region a dropped target carries, as they do for every other delivery.

Dereferencing a URI

A content state that is a bare URI is fetched and the returned document is parsed, so the URI form works whether it points at a content-state Annotation or straight at a Manifest. The request goes through the same fetch path manifest-id uses and gets the same trust, so your Content Security Policy controls it.

Failures are reported, never thrown

Ingestion never throws. What cannot be honored degrades, and problems are reported on the viewererror channel under the scope content-state:

Code

Meaning

content-state-dereference-failed

The URI could not be fetched. The viewer falls back to loading it as a manifest.

content-state-unresolved

Nothing in the content state named a Manifest, so nothing was loaded.

Partially supported shapes (a multi-target array, a partOf naming only a Collection, a missing motivation) resolve to the most the viewer can honor and log a dev-mode warning instead.

Partly supported content states

Resolution never throws. A content state that is partly unsupported degrades to the most that can be honored, and null comes back only when no manifest is resolvable at all:

  • A target array resolves its first entry; the rest are dropped with a dev-mode warning.

  • A partOf array resolves the first entry whose type is Manifest. When no entry declares a type at all, the first entry is taken, since untyped references are common in practice. When entries are typed but none is a Manifest (a Collection, say), nothing is resolved and a dev-mode warning names what was found: fetching a Collection as a Manifest would fail anyway.

  • A motivation that is missing or is not contentState still resolves, with a dev-mode warning. A document that names a Manifest is honored whatever its motivation.

  • Identifiers and types are read in both the IIIF Presentation 3 (id, type) and Presentation 2 (@id, @type) spellings.

An annotation-level source is not honored as a target; it is not a spec form.

Dev-mode warnings go through the viewer's logger, which is silent unless you enable debug mode.

Supported forms

Every form below is pinned by a committed fixture that the unit tests parse, and this table is generated from those fixtures, so it cannot list a form no test covers. Fixtures whose shape comes from a IIIF Cookbook recipe are constructed over that recipe's vendored manifest; none of them is fetched, at test time or at documentation-build time.

18 committed fixtures, each parsed by packages/core/src/lib/utils/contentState.test.ts. Nothing here is fetched.

FormResolves viaFixtureCookbook recipeCaptured
Bare IIIF URIReturned as the manifest id for the caller to dereferencebare-uri.txt0009-book-12026-08-20
base64url-encoded AnnotationDecoded, then parsed as an Annotationencoded-annotation.txt0299-region2026-08-20
The Cookbook's own published iiif-content value for recipe 0485base64url decoded, then percent-decoded, then parsed as an Annotation0485-published.txt0485-contentstate-canvas-region2026-08-22
Annotation, target as stringThe target string is the Canvas; the Annotation's partOf names the Manifeststring-target-region.json0299-region2026-08-20
Annotation, target as objecttarget.id is the Canvas; target.partOf names the Manifestobject-target-partof-array.json0299-region2026-08-20
Annotation, target as object with a single partOftarget.partOf as a bare object rather than an arrayobject-target-partof-object.json0009-book-12026-08-20
Annotation, target a SpecificResource naming the Canvas through sourcetarget.source.partOf names the Manifest; the FragmentSelector names the regionspecific-resource-source-partof.json0306-linking-annotations-to-manifests2026-09-13
Annotation, target as object carrying a #t= media timeThe fragment on target.id, through the shared IIIF target helpersobject-target-time.json0002-mvm-audio2026-08-20
partOf as array, Collection firstFirst entry whose type is Manifest, skipping the Collectionpartof-array-collection-first.json0009-book-12026-08-20
partOf as array, no entry declaring a typeFirst entry, since none declares Manifestpartof-array-untyped.json0009-book-12026-08-20
partOf as array, typed but naming no ManifestNothing — a Collection is not fetchable as a manifest, so it degrades rather than resolvingpartof-array-no-manifest.json0032-collection2026-08-20
Annotation, target as arrayFirst entry; the rest are dropped with a dev-mode warningtarget-array.json0009-book-12026-08-20
motivation as the bare string contentStateAccepted alongside the array formmotivation-string.json0009-book-12026-08-20
motivation absent or not contentStateResolved anyway, with a dev-mode warningmotivation-missing.json0009-book-12026-08-20
Annotation spelling identifiers @id and types @typeBoth spellings read at every levellegacy-at-id.json0009-book-12026-08-20
A Manifest document rather than an AnnotationThe document's own idmanifest-document.json0009-book-12026-08-20
A Canvas document naming its Manifest in partOfpartOf names the Manifest; the document's own id is the view targetcanvas-document-partof.json0009-book-12026-08-20
Annotation naming a Canvas but no ManifestNothing — no manifest is resolvableno-manifest.json0009-book-12026-08-20

Tracking Cookbook changes

Those fixtures were captured from Cookbook recipes on the dates recorded above, and the Cookbook keeps changing. A scheduled advisory job, .github/workflows/recipe-drift.yml, fetches the live recipes weekly and reports where the recipe catalog, the vendored manifests or a fixture's pinned manifest and canvas no longer match what iiif.io publishes. It files a GitHub issue; it never fails a build, and it is never a required check. A maintainer decides what to update.

Run it locally against the live Cookbook:

node scripts/recipe-drift.mjs                        # all 67 cataloged recipes
node scripts/recipe-drift.mjs --recipe 0009-book-1   # spot-check one