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 says what to show. It says nothing about how it reached you.

The iiif-content request parameter is one delivery channel for it. So are a paste, a drop, a FileReader, and a data-* attribute. The channel is yours by default: the viewer reaches for none of them on its own, because a component that claimed the address bar could hijack your routing or consume an iiif-content parameter meant for the page around it, and one that swallowed every drop could take a drag your page meant to handle (ADR 0006). What it will do is accept the payload from you, on the content-state input — and, if you say so explicitly, take that one parameter or a drop on your behalf.

You can also skip the viewer entirely. 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, on every distribution — the Svelte component, the custom element, and the React and Vue wrappers. The first is the payload; the other two each hand the viewer 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 exactly 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 stay yours.

<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, and never will — there is no second read to catch it. 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 dropped into a page it does not own must not swallow a drag the page meant to handle itself.

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

The payload is read from the drag’s text/plain flavour and nothing else — that is where the recipe’s own drag source puts it, and text/uri-list is deliberately not read, because a browser fills that flavour with an image’s src whenever the drag started from an <img>. Every form the conformance 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-driving API and win. The URL is ambient and lowest-trust. Setting manifest-id therefore turns a content state into a no-op rather than a conflict — which is what lets an application keep its own routing and still opt into the parameter as a fallback.

The tier wins input by input, not just 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 sits above all of it, and only for the manifest. It is a gesture the reader made on this viewer, so refusing to open what it names because the host had set manifest-id would be answering the wrong person; canvas-id and initial-canvas-region still win over the canvas and region a dropped target carries, exactly as they do for every other delivery.

Dereferencing a URI

A content state that is a bare URI is fetched, and the document that comes back 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; it introduces no new trust boundary, and your Content Security Policy is the control on it.

Failures are reported, never thrown

Ingestion never throws. What cannot be honored degrades, and anything worth your attention arrives 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.

Degradation, not rejection

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 — untyped references are common in the wild. 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 worth honoring whatever it claims to motivate.

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

Two spec-legal shapes are deliberately not resolved, because neither appears in any IIIF Cookbook recipe the fixtures are drawn from:

  • A target that is a SpecificResource — a source plus a selector — is not unwrapped. The Manifest is looked for on target.partOf, not on target.source.partOf, and a FragmentSelector's xywh= region or t= time is not read. Region and time are read only from the fragment on the target's own id.

  • An annotation-level source (not a spec form) is not honored as a target.

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 claim 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

Keeping the claim honest

Those fixtures were captured from cookbook recipes on a date, 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