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 |
|---|---|---|
|
| — |
|
| off |
|
| 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 parameterThe 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 |
|---|---|
| The URI could not be fetched. The viewer falls back to loading it as a manifest. |
| 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
targetarray resolves its first entry; the rest are dropped with a dev-mode warning.A
partOfarray resolves the first entry whose type isManifest. 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 aManifest(aCollection, say), nothing is resolved and a dev-mode warning names what was found: fetching a Collection as a Manifest would fail anyway.A
motivationthat is missing or is notcontentStatestill 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.
| Form | Resolves via | Fixture | Cookbook recipe | Captured |
|---|---|---|---|---|
| Bare IIIF URI | Returned as the manifest id for the caller to dereference | bare-uri.txt | 0009-book-1 | 2026-08-20 |
| base64url-encoded Annotation | Decoded, then parsed as an Annotation | encoded-annotation.txt | 0299-region | 2026-08-20 |
The Cookbook's own published iiif-content value for recipe 0485 | base64url decoded, then percent-decoded, then parsed as an Annotation | 0485-published.txt | 0485-contentstate-canvas-region | 2026-08-22 |
Annotation, target as string | The target string is the Canvas; the Annotation's partOf names the Manifest | string-target-region.json | 0299-region | 2026-08-20 |
Annotation, target as object | target.id is the Canvas; target.partOf names the Manifest | object-target-partof-array.json | 0299-region | 2026-08-20 |
Annotation, target as object with a single partOf | target.partOf as a bare object rather than an array | object-target-partof-object.json | 0009-book-1 | 2026-08-20 |
Annotation, target a SpecificResource naming the Canvas through source | target.source.partOf names the Manifest; the FragmentSelector names the region | specific-resource-source-partof.json | 0306-linking-annotations-to-manifests | 2026-09-13 |
Annotation, target as object carrying a #t= media time | The fragment on target.id, through the shared IIIF target helpers | object-target-time.json | 0002-mvm-audio | 2026-08-20 |
partOf as array, Collection first | First entry whose type is Manifest, skipping the Collection | partof-array-collection-first.json | 0009-book-1 | 2026-08-20 |
partOf as array, no entry declaring a type | First entry, since none declares Manifest | partof-array-untyped.json | 0009-book-1 | 2026-08-20 |
partOf as array, typed but naming no Manifest | Nothing — a Collection is not fetchable as a manifest, so it degrades rather than resolving | partof-array-no-manifest.json | 0032-collection | 2026-08-20 |
Annotation, target as array | First entry; the rest are dropped with a dev-mode warning | target-array.json | 0009-book-1 | 2026-08-20 |
motivation as the bare string contentState | Accepted alongside the array form | motivation-string.json | 0009-book-1 | 2026-08-20 |
motivation absent or not contentState | Resolved anyway, with a dev-mode warning | motivation-missing.json | 0009-book-1 | 2026-08-20 |
Annotation spelling identifiers @id and types @type | Both spellings read at every level | legacy-at-id.json | 0009-book-1 | 2026-08-20 |
| A Manifest document rather than an Annotation | The document's own id | manifest-document.json | 0009-book-1 | 2026-08-20 |
A Canvas document naming its Manifest in partOf | partOf names the Manifest; the document's own id is the view target | canvas-document-partof.json | 0009-book-1 | 2026-08-20 |
| Annotation naming a Canvas but no Manifest | Nothing — no manifest is resolvable | no-manifest.json | 0009-book-1 | 2026-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