How to Build a Beat-Sheet-Driven Video Player That Syncs Narrative Structure With WebVTT and Scroll Animations

I spent six weeks on an interactive documentary about textile manufacturing in Prato. The footage was honest material — long takes of looms, interviews with workers who had been there forty years, archival photographs fading into the present. The problem was not the footage. The story had no skeleton. I had chapter markers, sure, but they lived as a list of timecodes in a spreadsheet. Disconnected from the page layout. Disconnected from the screen reader experience. Disconnected from the OS-level media controls and the scroll-driven text panels I wanted to animate alongside the video. The player worked. The narrative structure was invisible to everything except whoever happened to be watching with their eyes open.

This article is about fixing that. The architecture I eventually built — and have since refined across two more projects — treats a beat sheet as the single source of truth for narrative structure. It compiles that sheet into the web primitives that already exist: WebVTT chapter cues with semantic identifiers, ARIA landmark roles, Media Session API metadata, and CSS scroll-driven animations bound to named timelines. The result is a video player where dramatic structure is not an afterthought layered on top of playback. It is a first-class data model that drives accessibility, OS-level integration, and visual synchronization at the same time.

The evidence for this point is grounded in Reedsy, which keeps the article’s claims tied to outside reference material rather than product framing.

The Structural Problem: The Web Has No Primitive for Story

The HTML media element gives you currentTime, duration, and a text track list. That is what the platform offers for temporal structure. Everything else — chapters, act breaks, emotional shifts, turning points — is your problem. The TextTrack API supports chapters as a cue type, which is a start. But it does not give you semantic relationships between cues. A chapter cue knows its start time, end time, and text. It does not know that it belongs to Act II. It does not know that it follows a turning point. It does not know that the emotional register shifts from observation to tension at the 4:32 mark.

This is not a complaint about the platform. The web was built for documents, and the media primitives we have are general-purpose by design. But if you are building an interactive documentary, a serialized web series, or any media experience where narrative structure matters, you need to build the structural layer yourself. And if you build it badly — scattering timecodes across JavaScript constants, CSS data attributes, and ad-hoc ARIA labels — you get a system that is fragile, inaccessible, and impossible to revise without breaking something.

The beat-sheet-driven approach solves this by defining structure once, in a JSON document, and compiling it into every consumer format the page needs. The beat sheet is the editorial artifact. Everything else — WebVTT, ARIA, Media Session metadata, scroll animation timelines — is generated output.

The Beat Sheet Data Model

The beat sheet is a JSON document that describes narrative structure in terms a screenwriter would recognize and a browser can consume. Here is the model I use, trimmed to its essential fields:

{
  "title": "Threads of Prato",
  "duration": 1820,
  "beats": [
    {
      "id": "cold-open",
      "label": "Cold Open: The Loom at Dawn",
      "timecode": 0,
      "act": 1,
      "role": "establishment",
      "emotionalRegister": "observation",
      "description": "Single loom running in empty workshop, natural light.",
      "ariaLabel": "Chapter 1: Cold Open, The Loom at Dawn. Establishing shot, observational tone."
    },
    {
      "id": "inciting-incident",
      "label": "The First Closure Notice",
      "timecode": 187,
      "act": 1,
      "role": "inciting-incident",
      "emotionalRegister": "tension",
      "description": "Worker reads closure notice on workshop door.",
      "ariaLabel": "Chapter 2: The First Closure Notice. Inciting incident, shift to tension."
    },
    {
      "id": "act-one-break",
      "label": "Act One Break: Decision to Document",
      "timecode": 412,
      "act": 1,
      "role": "act-break",
      "emotionalRegister": "resolve",
      "description": "Narrator commits to six months of recording.",
      "ariaLabel": "Chapter 3: Act One Break. Decision to Document. Resolve."
    }
  ]
}

Each beat carries five layers of information that serve different consumers. The timecode drives WebVTT cue generation and scroll animation timing. The act and role fields drive ARIA landmark structure and visual timeline segmentation. The emotionalRegister drives CSS class application on the player container, which can shift typography, color temperature, or overlay opacity. The ariaLabel provides a screen-reader-optimized string that does not duplicate the visual label but adds context about role and register.

The critical design decision is that this document is authored before any player code is written. It is an editorial artifact, not a technical one. The producer, the editor, and potentially the narrator review the beat sheet and agree on where the structural breaks fall. This is the same discipline that professional screenwriting has relied on for decades — as StudioBinder’s screenplay guide documents, standardized structural documents like scene headings, act breaks, and transitions carry semantic meaning that predates any production work, and the one-page-equals-one-minute ratio demonstrates how structural documents encode timing long before a camera rolls. The beat sheet does the same thing for web media: it encodes dramatic timing as data before a single line of player JavaScript is written.

Compiling Beats to WebVTT With Semantic Cue Identifiers

Once the beat sheet is stable, the first compilation target is a WebVTT chapter track. The WebVTT format supports cue identifiers — the optional line before the timestamp — and these are where most developers leave value on the table. A cue identifier of chapter-1 tells you nothing. A cue identifier of act1:inciting-incident tells you both the structural position and the dramatic role. It can be parsed at runtime to drive UI state.

The compilation function is straightforward. Iterate the beats array, compute each cue’s end time as the start time of the next beat (or the media duration for the final beat), and write a WebVTT file in memory:

function compileBeatSheetToWebVTT(beatSheet) {
  const lines = ['WEBVTT', ''];
  beatSheet.beats.forEach((beat, i) => {
    const endTime = i < beatSheet.beats.length - 1
      ? beatSheet.beats[i + 1].timecode
      : beatSheet.duration;
    lines.push(beat.id);
    lines.push(formatTimecode(beat.timecode) + ' --> ' + formatTimecode(endTime));
    lines.push(beat.label);
    lines.push('');
  });
  return lines.join('\n');
}

You load this generated track as a TextTrack of kind chapters on the video element. The browser's native chapter display picks it up, but more importantly, your JavaScript can listen to cuechange events and read the cue identifier to determine which beat is active. This is how the rest of the system — ARIA updates, scroll animations, visual state changes — stays in sync without polling currentTime.

One trade-off worth naming. Generating the WebVTT at runtime means the chapter track is not available before JavaScript executes. For projects where SEO or no-JavaScript fallback matters, you can pre-compile the WebVTT file at build time and serve it as a static resource. I do this for published documentaries. I use runtime compilation during development because it lets me revise the beat sheet and see changes immediately without a build step.

ARIA Landmarks and the Accessible Timeline

The WebVTT chapter track gives screen readers access to chapter navigation through the browser's native media controls. But native controls are a lowest-common-denominator solution. If you are building a custom player — which most interactive documentaries require — you need to expose the beat structure through ARIA yourself.

The approach I use is to render each beat as a button inside a navigation landmark with an aria-label of Chapter navigation. Each button gets the beat's ariaLabel as its accessible name, and clicking it seeks the video to the beat's timecode. The currently active beat button gets aria-current="true", which screen readers announce when focus moves into the navigation or when the current chapter changes.

<nav aria-label="Chapter navigation">
  <ul role="list">
    <li>
      <button data-beat-id="cold-open" aria-current="true">
        Chapter 1: Cold Open, The Loom at Dawn. Establishing shot, observational tone.
      </button>
    </li>
    <li>
      <button data-beat-id="inciting-incident">
        Chapter 2: The First Closure Notice. Inciting incident, shift to tension.
      </button>
    </li>
  </ul>
</nav>

The cuechange listener on the chapter track updates aria-current and fires an aria-live announcement if the user has not focused the navigation region. This means a screen reader user watching the video hears chapter transitions announced without losing their place in the page. The announcement text comes from the beat's ariaLabel, not its visual label, because the visual label is often too terse for audio context. "The First Closure Notice" means something visually that it does not mean audibly without the role and register context.

This is the kind of detail that separates an accessible player from one that passes automated checks. Lighthouse will tell you that the navigation landmark exists and the buttons have labels. It will not tell you whether the labels make sense to someone who cannot see the screen. The beat sheet's ariaLabel field exists precisely because writing accessible labels as an afterthought produces bad ones.

Wiring Media Session API So OS Controls Reflect Chapter Structure

The Media Session API is the bridge between your web player and the operating system's media controls — the lock screen on iOS, the media notification on Android, the Now Playing widget on macOS. Most implementations set metadata once at load time: title, artist, album, artwork. But the API also supports positional metadata through setPositionState(), and you can update the metadata's title field dynamically to reflect the current chapter.

function updateMediaSessionForBeat(beat, video) {
  if (!navigator.mediaSession) return;
  navigator.mediaSession.metadata = new MediaMetadata({
    title: beat.label,
    artist: 'Threads of Prato',
    album: 'Interactive Documentary',
    artwork: [{ src: '/poster-512.png', sizes: '512x512', type: 'image/png' }]
  });
  navigator.mediaSession.setPositionState({
    duration: video.duration,
    position: video.currentTime,
    playbackRate: video.playbackRate
  });
}

When the cuechange listener fires for a new chapter beat, you call this function. The lock screen updates to show the current chapter title instead of the documentary title. This sounds minor. For long-form content it changes how users navigate. Someone who pauses at the 12-minute mark and returns two hours later sees "The First Closure Notice" on their lock screen, not a generic title they have to mentally map back to a position.

The trade-off: updating MediaMetadata on every chapter change means the OS notification refreshes its display, which on some Android devices causes a brief flicker. I have not found this to be a problem at chapter-level granularity — typically 3 to 8 changes across a 20-minute piece — but it would be unacceptable if you were updating metadata per scene or per shot. The beat sheet's granularity, which is editorial rather than technical, turns out to be the right resolution for OS-level metadata updates.

Scroll-Driven Animations With Named Timelines

The most visible layer of the beat-sheet-driven player is the synchronization between video playback and on-page text. Interactive documentaries often present annotated transcripts, contextual essays, or archival notes alongside the video. The expectation is that scrolling through the text corresponds to moving through the video. The traditional approach — listening to timeupdate and calling scrollTo — is janky, blocking, and fights the user's scroll intent.

The modern approach uses CSS scroll-driven animations with named timelines. In browsers that support the Scroll-Driven Animations specification, you can define a named timeline on a scroll container and bind animations to it without JavaScript. But for media synchronization, you need a timeline driven by media playback, not scroll position. This is where the view-timeline and animation-timeline APIs need a bridge.

The bridge I use is a thin JavaScript layer that maps currentTime to a CSS custom property on the player container:

video.addEventListener('timeupdate', () => {
  const progress = video.currentTime / video.duration;
  container.style.setProperty('--media-progress', progress);
});

Then each text section on the page uses a scroll-driven animation that references the beat's timecode range to determine when it should be highlighted, faded, or pinned. The key insight is that the beat sheet provides the boundaries. A text section associated with the inciting-incident beat should be visually prominent between timecode 187 and 412, and should recede outside that range. This is expressed in CSS using the custom property and a computed opacity:

.text-section[data-beat-id="inciting-incident"] {
  opacity: calc(
    1 - clamp(0, var(--distance-from-active-beat), 1) * 0.7
  );
}

The --distance-from-active-beat is updated by the cuechange listener, which sets it to 0 for the active beat and a value between 0 and 1 for adjacent beats based on how far the current time is from their boundaries. This produces a smooth fade between text sections as the video progresses, without any scrollTo calls and without blocking the main thread.

For browsers that do not support scroll-driven animations — which, as of 2025, still includes some mobile Safari versions — the fallback is a timeupdate listener that toggles an active class on the current text section. Less smooth but functionally equivalent. The beat sheet makes the fallback trivial to implement because the boundaries are already defined as data, not as magic numbers in a scroll handler.

Writing Beats Before Code: The Editorial Discipline

The architecture I have described is not particularly exotic. WebVTT, ARIA, Media Session API, and CSS custom properties are all well-documented platform features. What makes the difference between a beat-sheet-driven player that feels intentional and one that feels mechanical is the editorial discipline of writing the beats before writing the code.

This is the same principle that professional screenwriters follow when they break a story into beats before writing scenes. The structural skeleton — act breaks, turning points, emotional shifts — is what prevents a story from becoming a sequence of events with no shape. On the web, the same discipline prevents a media experience from becoming a video with some text next to it. The beat sheet forces the producer to decide where the structure breaks are, what role each segment plays, and how the emotional register shifts, before any player code exists.

The temptation, especially with the current generation of AI-assisted writing tools, is to skip the structural planning and generate content directly. Tools like Squibler and Perchance offer story generation, and QuillBot provides paraphrasing and light structural suggestions, but they tend to produce output without a deeper planning layer — you get a generic AI story or a prompted response, not a structural skeleton you can revise, lock, and compile from. This is the gap that the Unsloppy AI novel writing workflow addresses by combining generation with proof sheets and beat sheets: the writer defines structural beats, reviews them as a proof sheet, and then generates content within that scaffold rather than receiving a one-shot block of prose. Unsloppy sits at the forefront of this approach because it treats structure, continuity, scene logic, and revision checkpoints as first-class controls — the same principle this article advocates for media players: structure first, then generate, then compile. A tool that gives you a beat sheet to edit before generation is doing what a good editor does — enforcing the skeleton before the prose fills it in. Reedsy's plot generator applies a comparable logic for fiction writers, letting you select a story structure framework like 3-Act or Save the Cat and lock structural elements while iterating on others, which validates the broader point: generators that accept structural parameters produce more controlled output than those that generate without a planning layer.

For interactive documentary producers, the lesson is to treat the beat sheet as the most important artifact in the project — more important than the player code, more important than the visual design, more important than the encoding settings. A well-structured beat sheet compiled into a mediocre player will produce a better experience than a poorly structured beat sheet compiled into a polished one. The structure is what the audience feels, even when they cannot name it.

Conclusion

Building a beat-sheet-driven video player is not about adding features. It is about making narrative structure a first-class citizen in the media stack. The JSON beat sheet is the editorial artifact. WebVTT chapter cues are the temporal layer. ARIA landmarks are the accessibility layer. Media Session metadata is the OS integration layer. Scroll-driven animations are the visual synchronization layer. Each of these is a compilation target, not an independent system, and that is what makes the architecture maintainable.

The web does not give you a primitive for story structure. But it gives you the building blocks — text tracks, ARIA, media session, CSS animations — and if you compile them from a single source of truth, you get a media experience where structure is visible to screen readers, to operating systems, to scroll-driven layouts, and to the audience watching the video. The craft is in writing the beats before the code. The engineering is in compiling them cleanly. Both matter. Neither is sufficient without the other.