A Svelte 5 component library for bidirectional synchronization between IIIF media playback and transcript navigation. Unstyled primitives for scholarly and archival applications.
Built on IIIF (International Image Interoperability Framework) — the standard used by libraries, museums, and archives worldwide.
Watch the components in action with a real IIIF video and VTT captions from the IIIF Cookbook. Click transcript segments to seek; play video to auto-scroll the active cue.
Loading transcript...
Each demo isolates one capability against a real IIIF manifest: four component features, and three real-world manifests.
IIIF Range navigation auto-parsed from manifest.structures — seek on click, active tracking.
annotations="auto" builds the transcript from the manifest’s own VTT track.
Transcript segments that carry inline HTML formatting.
onPlayerInit gives a component outside Root read access to the live PlayerRef.
Audio from IU Media Collections Online, played from its published IIIF manifest.
A 1954 NAEB radio program with no IIIF at the source — wrapped in a hand-authored manifest.
Supplementary canvas.annotations with multi-body speaker tags.
Transcript text is invisible to search engines when it only lives
inside a VTT file. By parsing VTT at build time with
media-captions, you can inject the full transcript into Schema.org/VideoObject structured data — making every word indexable by Google, Bing, and AI
systems.
This page practices what it preaches.
View source on this page to see the <script type="application/ld+json">
in the <head> — generated at build time from the same IIIF manifest that powers the
interactive demo above.
In an Astro page, the frontmatter runs at build time on the
server. Fetch the same IIIF manifest the player uses, extract the
transcript text, and inject it as
Schema.org structured data. The manifest is the single source of truth for both
the player and search engines.
---
// Astro frontmatter — runs at build time
import { parseText } from 'media-captions';
import {
ManifestSchema, getFirstCanvas,
buildTranscriptAnnotations, getSupplementaryVTTTracks,
} from '@umd-mith/iiif-timed-transcript';
const manifestUrl = 'https://example.org/manifest.json';
// Fetch and validate the IIIF manifest
const manifest = await fetch(manifestUrl).then(r => r.json());
const { data } = ManifestSchema.safeParse(manifest);
const canvas = data ? getFirstCanvas(data) : undefined;
let transcript = '';
if (canvas) {
// Path A: Transcript embedded as IIIF annotations
const { annotations } = buildTranscriptAnnotations(canvas);
if (annotations.length > 0) {
transcript = annotations.map(a => a.text).join(' ');
} else {
// Path B: Manifest references an external VTT file (recipe 0219)
const tracks = getSupplementaryVTTTracks(canvas);
if (tracks.length > 0) {
const vttText = await fetch(tracks[0].src).then(r => r.text());
const { cues } = await parseText(vttText, { type: 'vtt' });
transcript = cues.map(cue => cue.text).join(' ');
}
}
}
const jsonLd = {
"@context": "https://schema.org",
"@type": "VideoObject",
"name": data?.label?.en?.[0] ?? "Untitled",
"transcript": transcript,
// ... thumbnailUrl, uploadDate, contentUrl
};
---
<head>
<script type="application/ld+json"
set:html={JSON.stringify(jsonLd)} />
</head> Path A handles manifests with embedded transcript annotations (like the AVAnnotate demo). Path B handles manifests that reference an external VTT file (IIIF Cookbook recipe 0219), using media-captions as a lightweight VTT parser.
media-captions?
media-captions is the caption parsing library from the Vidstack project (3,400+ GitHub stars). MIT licensed, actively maintained, and built specifically to address the gaps in native browser caption support.
Follows the W3C WebVTT specification for parsing and rendering — correct cue settings, region positioning, and collision detection. Not a regex approximation, but a proper spec implementation.
Built to meet FCC Communications and Video Accessibility Act
requirements. Correct caption positioning matters for WCAG
compliance — native browser ::cue styling is too limited for accessible overlay rendering.
Works in Astro frontmatter (Node/build time) and in Svelte components (browser/runtime). One dependency for SEO extraction and interactive playback — no environment-specific forks.
Parses VTT, SRT, and SSA/ASS. TypeScript-native with proper VTTCue types. Tree-shakes to just the parser you use. Zero dependencies.
Without structured data, crawlers see a <video> tag and a VTT URL — opaque binary content. With the transcript property, every spoken word becomes indexable text:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "VideoObject",
"name": "Lunchroom Manners",
"transcript": "Do you know the lunchroom rules? ..."
}
</script>
The transcript property is defined
on
Schema.org/AudioObject
and
Schema.org/VideoObject
(inherited from MediaObject). Works for both audio and video content.
Install the library and start building synchronized media experiences in minutes. No configuration required.
npm install @umd-mith/iiif-timed-transcript
Peer dependencies: svelte@^5
<script>
import { IIIFPlayer, type Annotation } from '@umd-mith/iiif-timed-transcript';
const manifestUrl = 'https://example.org/manifest.json';
// Parsed VTT or IIIF annotations
const annotations: Annotation[] = [
{ id: 'cue-0', startTime: 0, endTime: 4.5, text: 'Welcome to the presentation.' },
{ id: 'cue-1', startTime: 4.5, endTime: 9.2, text: 'Today we will discuss...' },
{ id: 'cue-2', startTime: 9.2, endTime: 14.0, text: 'Let us begin with the first topic.' }
];
</script>
<IIIFPlayer.Root {manifestUrl} canvasIndex={0}>
<IIIFPlayer.Viewer />
<IIIFPlayer.Controls>
<IIIFPlayer.PlayButton />
<IIIFPlayer.Progress />
<IIIFPlayer.Skip seconds={-10} />
<IIIFPlayer.Skip seconds={30} />
<IIIFPlayer.Speed />
<IIIFPlayer.Time />
</IIIFPlayer.Controls>
<IIIFPlayer.Transcript {annotations}>
<IIIFPlayer.TranscriptSearch />
<IIIFPlayer.TranscriptSegments />
</IIIFPlayer.Transcript>
</IIIFPlayer.Root> The compound components work seamlessly in Astro islands with proper hydration directives:
<IIIFPlayer.Root client:load {manifestUrl} canvasIndex={0}>
<IIIFPlayer.Viewer />
<IIIFPlayer.Controls>
<IIIFPlayer.PlayButton />
<IIIFPlayer.Progress />
<IIIFPlayer.Skip seconds={-10} />
<IIIFPlayer.Skip seconds={30} />
<IIIFPlayer.Speed />
<IIIFPlayer.Time />
</IIIFPlayer.Controls>
<IIIFPlayer.Transcript {annotations}>
<IIIFPlayer.TranscriptSearch />
<IIIFPlayer.TranscriptSegments />
</IIIFPlayer.Transcript>
</IIIFPlayer.Root> Hydration tip: Use client:load on the wrapper component (here, Root) for immediate media initialization.
Child components inside the hydration boundary do not need their own
client: directives
— Astro hydrates the entire island.
Composable building blocks for creating custom IIIF media player interfaces. All components are unstyled primitives with data-* attributes for styling.
Top-level context provider that manages player state and coordinates all child components. Provides PlayerContext to children.
Renders the IIIF media resource (video/audio) from the current canvas. Receives PlayerContext from Root.
Container for player control components. Use as layout wrapper for PlayButton, Progress, Skip, etc.
Bidirectional synchronized transcript panel. Click segment to seek, video auto-scrolls active segment. Compose with compound children for full control over layout.
Use the segment snippet on TranscriptSegments for full control over how each segment renders. The segmentAttrs spread provides all a11y attributes, data attributes, tabindex, and
click handling automatically.
<IIIFPlayer.Transcript {annotations}>
<IIIFPlayer.TranscriptSearch />
<IIIFPlayer.TranscriptSegments>
{#snippet segment({ annotation, isActive, segmentAttrs })}
<button {...segmentAttrs}>
{#if annotation.metadata?.speaker}
<strong>{annotation.metadata.speaker}:</strong>
{/if}
<span class:active={isActive}>{annotation.text}</span>
</button>
{/snippet}
</IIIFPlayer.TranscriptSegments>
</IIIFPlayer.Transcript>
The segmentAttrs object includes
data-annotation-id, data-state, aria-current, role, tabindex, and onclick — so you never need to wire those up manually. See the AVAnnotate demo for a full example with speaker tags and filtering.
All components ship unstyled with data-* attributes. Here's a minimal example to get started.
/* Target transcript segments via data attributes */
[data-annotation-id] {
padding: 0.75rem;
margin-bottom: 0.5rem;
border-radius: 0.25rem;
transition: background-color 0.2s;
text-align: left;
}
[data-annotation-id]:hover {
background-color: #f3f4f6;
}
/* Active segment highlighting */
[data-annotation-id][data-state='active'] {
background-color: #bae6fd;
border-left: 4px solid #0ea5e9;
}
/* Search match highlighting */
[data-annotation-id][data-highlighted='true'] {
background-color: #fef9c3;
}
/* Current search match (stronger) */
[data-annotation-id][data-current-match='true'] {
background-color: #fde68a;
border-left: 4px solid #eab308;
}
[data-annotation-id] .timestamp {
font-size: 0.75rem;
color: #6b7280;
font-weight: 600;
}
[data-annotation-id] .text {
color: #1f2937;
line-height: 1.5;
} /* Style control bar via data attributes */
[data-audio-controls] {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
background: white;
border-radius: 0.5rem;
}
[data-audio-button] {
padding: 0.5rem;
background: #f3f4f6;
border-radius: 0.25rem;
transition: background-color 0.2s;
}
[data-audio-button]:hover {
background: #e5e7eb;
}
/* Progress bar */
input[type="range"][data-audio-progress] {
width: 100%;
height: 0.5rem;
cursor: pointer;
accent-color: #0ea5e9;
}
/* Speed selector */
select[data-audio-control="speed"] {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
/* Time display */
[data-audio-control="time"] {
font-size: 0.875rem;
font-variant-numeric: tabular-nums;
}