Authoring IIIF stories and tours
How to encode a story as a IIIF Presentation 3 manifest that this viewer reads, including the annotation-driven tour stops it accepts.
The viewer also reads legacy storymap JSON; see the README for that format. This page covers the IIIF side. docs/storymap-as-iiif-manifest.md documents the full property mapping; this page is the task-oriented version.
The shortest possible story
One canvas, one slide:
{
"@context": "http://iiif.io/api/presentation/3/context.json",
"id": "https://example.org/story/1",
"type": "Manifest",
"label": { "en": ["A story"] },
"items": [
{
"id": "https://example.org/story/1/canvas/1",
"type": "Canvas",
"height": 3000,
"width": 2315,
"label": { "en": ["The first slide"] },
"summary": { "en": ["Its body text."] },
"items": [
{
"type": "AnnotationPage",
"items": [
{
"type": "Annotation",
"motivation": "painting",
"body": {
"id": "https://example.org/image/full/max/0/default.jpg",
"type": "Image",
"format": "image/jpeg",
"service": [
{
"id": "https://example.org/image/info.json",
"type": "ImageService3",
"profile": "level2"
}
]
},
"target": "https://example.org/story/1/canvas/1"
}
]
}
]
}
]
}
One canvas is one slide: label becomes the headline, summary the body
text, and the painting annotation's body the slide media. There is also one
slide for a Canvas that has no painting at all.
That service array on the body is also where the basemap comes from. With
storymap:basemap: "iiif" the viewer takes the first painting body carrying an
ImageService3 as the image basemap and fetches its info.json, which is how
iiif.url is resolved — so the basemap service is spelled the standard way and
needs no mapconfig term.
Guided tours: one image, many stops
This is the Micrio/Storiiies shape and the reason the format exists here: a
single canvas shown as a map, with one slide per detail. An annotation with
commenting, tagging, classifying or describing and a fragment
target becomes a slide, and the view fits the region the target points at.
Two things are required:
- The canvas must be presented as an image map — a
map configuration service
with
storymap:basemap: "iiif"andstorymap:mapAsImage: true. Region stops are honoured in image mode; a geographic map ignores them. - The target must resolve to a region. An annotation aimed at the whole canvas is not a stop — the canvas slide already shows that.
"service": [
{
"id": "https://example.org/story/1/map-config",
"type": "Service",
"profile": "https://cmahnke.github.io/StoryMapJS/context.json/mapconfig",
"storymap:basemap": "iiif",
"storymap:mapAsImage": true
}
]
Then the stops, on the canvas, after its painting:
{
"type": "AnnotationPage",
"items": [
{
"id": "https://example.org/story/1/canvas/1/annotation/2",
"type": "Annotation",
"motivation": "commenting",
"label": { "en": ["The lower group"] },
"body": {
"type": "TextualBody",
"format": "text/plain",
"value": "First paragraph.\n\nSecond paragraph."
},
"target": {
"type": "SpecificResource",
"source": "https://example.org/story/1/canvas/1",
"selector": {
"type": "FragmentSelector",
"value": "xywh=120,1500,1100,900"
}
}
}
]
}
The annotation label becomes the stop's headline — that is what the slider
and the map marker show. Stops render in annotation order, after the canvas
slide.
Target selectors
| Selector | Becomes |
|---|---|
FragmentSelector / ImageApiSelector with xywh=x,y,w,h |
that image-pixel region. xywh=pixel:… works too. A negative origin is clamped to the canvas (targets routinely overshoot it); a region fully outside it is dropped |
PointSelector (x, y) |
a square of 5% of the canvas's smaller side, centred on the point and clamped to the canvas — a pin you can fit |
TextQuoteSelector (exact, optional prefix/suffix) |
preserved; not resolved, because a canvas carries no transcript. Use it to anchor a stop to quoted text in a tool that has one |
TimeState (start, end) or a start/end range |
preserved on the stop; a time-ranged narration can be built from it |
SvgSelector |
preserved on the stop; not resolved and not fitted — an annotation with only an SvgSelector (no region) is not a stop, because there is no region to fit |
One level of refinedBy is followed, so a fragment refined by a quote works.
Bodies
A stop's body may be one resource or an array.
- Text — a
TextualBodybecomes the slide text.text/plainis escaped and split into one paragraph per blank-line-separated block;text/htmlis kept as markup. Slide text is sanitized when it renders, so a body cannot inject script. - Media — a body with an
id(typicallytype: "Sound") becomesmedia.url. The viewer resolves the URL to a player: a.mp3/.wav/.m4aURL renders a native<audio>, a YouTube or Vimeo URL renders that embed, and anything else falls back to a framed website. ASoundbody therefore needs no type of its own beyondidandformat. Playback flags ridestorymap:members on the painting annotation —storymap:offset(start seconds),storymap:loop,storymap:play(auto/click) andstorymap:stopOnExit— and amotivation: "supplementing"Sound/Video body becomes the slidenarrationwith the same flags plusstorymap:stopAllPrevious. These fields are read from any manifest but render only for slideshow tours (the viewer'sslideshow_sourcemarker); hand-written manifests keep the long-standing playback. - A media body's
labelbecomes the media caption,accessibilitySummarythe alt text, andrequiredStatement(orprovider) the credit. Athumbnailbecomes the media thumb. A siblingTextualBodywithformat: "text/vtt"and anidbecomes the media's subtitle track — IIIF has no subtitle term, so this is the one body the viewer reads for that purpose. - An annotation with a region but no body is not a stop — a stop with nothing to say is just a focus change. Use it in a viewer that links annotations.
Locations, groups and attribution
- A point on a map — navPlace
on the canvas. A
Pointgives the slide's location; aPolygongives the story's extent (map_bbox). Marker presentation (icon,iconSize,image,name,line,zoom,rotationin degrees clockwise,basemapfor a per-slide basemap,filterfor per-slide image grading,maskfor a spotlight mask,use_custom_marker) goes in the Feature'sproperties. All stops on a canvas share that canvas'snavPlace— the extension does not allow it on an annotation, so there is no per-stop override. Twopropertiesnames are marker behaviour, not data:popup: truegives the active marker a card with the slide's headline and excerpt, andaudioBadge: truedots a marker whose slide has narration or audio. Open the card from code withstorymap.openMarkerPopup(n)(it navigates to the stop first) and listen forpopupopen/popupclosewith{ marker_number, current_slide }. The thirteen names the reader copies — and the linked-data context that describes them — are listed indocs/storymap-as-iiif-manifest.md. - Chapters — a
Rangewith alabeland nostartgroups the canvases it lists. ARangewhoseitemsorder differs from canvas order becomes the slide order instead: a Range is a storyboard. - Attribution — a
requiredStatementobject on the manifest, with alabeland avalue(both language maps). It lands in the map's attribution line. - Language — every label, summary and property value is a language map.
The viewer renders the language it was configured with, and falls back to
none.
Annotations in a separate file
A Canvas can point at an AnnotationCollection with seeAlso, and the viewer
follows it. Nothing is fetched while the story loads — that would put a network
round trip in front of the first paint — so the stops on the canvas appear
immediately, and a host asks for the rest afterwards:
await sm.loadAnnotations();
sm.on("annotationsloaded", (result) => {
console.log(`added ${result.stops.length} stops`);
});
One level is followed: a referenced AnnotationPage is read, but a seeAlso
inside that page is not, and a SearchService1 is recorded rather than
followed. Stops from a referenced page are appended after the canvases
already rendered, not placed after the canvas they annotate, because that canvas
has been visited. A uniqueid is required, so a stop can be deep-linked.
What this viewer does not read yet
- An
SvgSelector's true outline (see above). - A
TextQuoteSelectoris not resolved against a transcript. accessibilityFeaturebeyond the alt-text mapping.
Checking your work
npm run validate:iiif # the official IIIF validator, over every example
public/examples-iiif/annotated-image.json
is a complete four-stop tour using everything above, and is what
e2e/annotations.spec.ts renders.