Video accessibility is one of the most consistently overlooked areas of web compliance. Most developers know how to add alt text for images, but video, captions, transcripts, audio descriptions, and contrast requirements are more complex and the tooling is thinner.
This guide documents how to build a Next.js 15 application that scores 100 out of 100 across all seven WCAG 2.1 A/AA video criteria, with Cloudinary’s AI pipeline handling the hardest requirements. Every step maps directly to a real criterion, with the engine code that satisfies it.
- Live demo: https://video-accessibility.vercel.app/
Live demo:
The Americans with Disabilities Act (ADA) has been applied to websites and web-hosted video since the landmark Netflix v. NAD settlement in 2012. Since then, accessibility-related lawsuits in the U.S. have grown year over year, with video captioning among the most frequently cited violations.
Beyond compliance, the population that benefits from accessible video is larger than most teams assume:
- 1.5 billion people worldwide have some degree of hearing loss.
- 2.2 billion people have a near or distance vision impairment.
- Captions are regularly used by people watching in noisy environments, non-native speakers, and anyone who processes information better by reading and listening simultaneously.
WCAG 2.1, published by the W3C, is the internationally recognized technical standard. Level A is the minimum; Level AA is the legal target in most jurisdictions (EU Web Accessibility Directive, ADA, Section 508).
This project targets every video-specific criterion at Level A and AA. Here’s the complete set, what each requires, and which Cloudinary feature satisfies it:
The architecture follows a three-stage flow:
- A video is uploaded to Cloudinary (direct browser upload via unsigned preset).
- A Next.js server action calls the Admin API with auto_transcription: true and auto_chaptering: true.
- The client polls the Admin API every 4 seconds until the .transcript sidecar file appears.
- The player is re-sourced with native caption discovery enabled; the transcript and chapters are served directly from Cloudinary’s CDN.
There isn’t a manual WebVTT authoring step anywhere in this flow.
What the criterion requires:
Every prerecorded video with an audio track must have a synchronized caption track, text displayed in sync with the spoken audio.
How Cloudinary handles it:
Cloudinary’s Video Transcription add-on accepts one API call and returns two sidecar files:
- {publicId}.transcript: a word-timed JSON file with start/end timestamps per word
- {publicId}.vtt: a standard WebVTT file
Both files live alongside the video in your Cloudinary account under the same public_id namespace. The Cloudinary Video Player can discover them natively, with no explicit URL required.
With one call, Cloudinary queues the AI job asynchronously and writes the sidecar files when complete.
Read the documentation here: uploader.explicit() reference · Video transcription overview
Read the documentation here: uploader.explicit() reference · Video transcription overview
View app/actions.ts on GitHub: https://github.com/musebe/video-accessibility/blob/main/app/actions.ts
View app/actions.ts on GitHub:
Most caption integrations require you to construct a CDN URL for the VTT file and pass it as a <track src="...">. With Cloudinary, the player knows the account’s cloud name and the video’s public_id, so it can find the .transcript JSON automatically.
Setting maxWords: 5 switches the player into paced subtitle mode. Rather than displaying a full caption block, it shows five words at a time, highlighting each word as it’s spoken (karaoke style). This is significantly more readable for users with cognitive disabilities or reading difficulties.
When a user has corrected the transcript and uploaded an edited VTT, the player switches to that explicit URL instead:
Read the documentation here: Cloudinary Video Player — Text Tracks
Read the documentation here: Cloudinary Video Player — Text Tracks
View components/VideoPlayer.tsx on GitHub: https://github.com/musebe/video-accessibility/blob/main/components/VideoPlayer.tsx
View components/VideoPlayer.tsx on GitHub:
What the criterion requires:
For prerecorded video with audio, either an audio description track or a full text alternative (transcript) that describes both the audio and visual content must be provided.
A transcript file sitting in storage does not satisfy this requirement. It must be presented to the user in a meaningful, accessible way.
How Cloudinary handles it:
The same .transcript JSON that drives captions becomes the data source for a scrolling transcript panel rendered beside the player. The panel stays in sync with playback by reading word-level timestamps from the transcript.
How the Admin API poll works:
Because Cloudinary processes transcription asynchronously, the app polls for the file every four seconds using the Admin API, which returns a versioned secure_url for raw sidecar files:
The version number in the URL is required; CDN URLs for raw files on this account type do not resolve without it.
Read the documentation here: Admin API — Get resource
Read the documentation here: Admin API — Get resource
The transcript panel walks the lines array backwards on every time update to find the last spoken line:
The active line scrolls into view automatically. Past lines fade to 38% opacity. Clicking any line seeks the player to that timestamp, making the transcript a fully interactive navigation tool rather than a plain text dump.
View components/TranscriptPanel.tsx on GitHub: https://github.com/musebe/video-accessibility/blob/main/components/TranscriptPanel.tsx
View components/TranscriptPanel.tsx on GitHub:
What the criterion requires:
Level AA requires an audio description track: a separate narration track that describes visual content not conveyed by the main audio. For many videos, this means producing and mixing an additional audio track, which is expensive and time-consuming.
WCAG 1.2.5 includes a recognized exemption. When all visual content is already described by the existing audio track, a separate audio description isn’t needed. A talking-head tutorial, screenshare walkthrough, or product demo where the speaker narrates what’s on screen qualifies.
How Cloudinary confirms it:
Cloudinary’s AI transcription confirms that the audio track is substantive. It produces a transcript with word-level content only if the audio contains speech. If the video were silent or contained only background music, the transcript would be empty or absent.
The application uses transcription completion as the confirmation signal:
The audit detail reads: “Speaker narration describes on-screen content — audio description satisfied by spoken commentary.”
View components/VideoAccessibilityClient.tsx on GitHub: https://github.com/musebe/video-accessibility/blob/main/components/VideoAccessibilityClient.tsx
View components/VideoAccessibilityClient.tsx on GitHub:
Chapters aren’t a hard requirement of WCAG by themselves, but they improve navigation for keyboard and screen reader users, especially for longer videos. They’re a WCAG best practice under Guideline 2.4 (Navigable).
How Cloudinary generates chapters:
The same uploader.explicit() call that triggers transcription also triggers AI chaptering when auto_chaptering: true is set. Cloudinary’s model segments the video by topic and writes chapter markers as part of the player’s internal chapter track.
How the player discovers chapters:
The chapters aren’t returned as a raw CDN file; they live as an internal text track within the player. The application polls p.textTracks() every three seconds after player init:
Once chapters are found, they’re passed up to the parent via onChaptersDiscovered and reflected in the player’s built-in chapters menu button (chaptersButton: true).
Read the documentation here: Cloudinary Video Player — Chapters
Read the documentation here: Cloudinary Video Player — Chapters
View components/VideoPlayer.tsx on GitHub:
View components/VideoPlayer.tsx on GitHub:
What the criterion requires:
Every player control (play, pause, seek, volume, fullscreen, captions toggle, chapters menu) must be reachable and operable via keyboard alone. This rules out custom players built from unsemantic HTML without explicit keyboard handling.
How Cloudinary handles it:
The Cloudinary Video Player is built on Video.js, which ships with a complete keyboard interaction model out of the box. No additional code is required.
The chapters button, caption style menu, and all player controls are fully tab-navigable. Setting controls: true in the player config is all that is needed.
Read the documentation here: Cloudinary Video Player configuration
Read the documentation here: Cloudinary Video Player configuration
View components/VideoPlayer.tsx on GitHub:
View components/VideoPlayer.tsx on GitHub:
What the criterion requires:
If content moves, blinks, or scrolls automatically for more than 5 seconds, a mechanism to pause or stop it must be provided. Autoplaying video is one of the most common violations and one of the most damaging experiences for users with attention disorders, vestibular disorders, or screen readers.
How Cloudinary handles it:
The Cloudinary Video Player doesn’t autoplay by default. The autoplay property must be explicitly set to enable it. By omitting it, the player starts in a paused state with the native play button always visible and focusable.
This single configuration omission satisfies the criterion for all videos in the application.
What the criterion requires:
Text rendered at normal size must have a contrast ratio of at least 4.5:1 against its background. This applies to player UI labels, caption text, and any overlaid information.
Two things working together:
1. Player color theme
The Cloudinary Video Player accepts a colors object that controls the control bar:
2. Caption text-shadow for video overlay
Caption text appears over unpredictable video content. Rather than requiring a black background box, a CSS text-shadow ensures legibility over any frame:
This approach keeps captions visually clean while maintaining WCAG-required contrast in all conditions.
View app/globals.css on GitHub: https://github.com/musebe/video-accessibility/blob/main/app/globals.css
View app/globals.css on GitHub:
What the criterion requires:
Every user interface component must have a programmatically determinable name that assistive technologies can read aloud. A <video> element with no label is announced by screen readers simply as “video”, which is ambiguous and unhelpful, especially when multiple videos appear on a page.
One attribute on the native video element:
Because the Cloudinary Video Player mounts on top of a standard <video> element that we control, the aria-label propagates through the player’s accessible DOM tree. In a production application this label should describe the video’s actual content (e.g., "Introduction to Cloudinary Transformations").
View components/VideoPlayer.tsx on GitHub:
View components/VideoPlayer.tsx on GitHub:
While not a WCAG hard requirement, providing caption style customization significantly improves the experience for users with low vision, photosensitivity, or dyslexia. The application ships six presets, each injected dynamically via a <style> block with no re-sourcing of the player required.
View components/CaptionStylePicker.tsx on GitHub: https://github.com/musebe/video-accessibility/blob/main/components/CaptionStylePicker.tsx
View components/CaptionStylePicker.tsx on GitHub:
The application includes a runWcagAudit() function that evaluates the runtime state of the player and returns a per-criterion pass/fail result with detailed text. This is what drives the live compliance panel visible in the application.
So, why runtime audit instead of static lint?
Some criteria can’t be evaluated statically. Whether captions exist depends on whether Cloudinary has finished transcribing. Whether autoplay is disabled depends on the player configuration at runtime. The audit runs on every render and updates live: the score moves from ~57 to 100 the moment the transcript polling completes.
The score is a percentage: passed / total × 100. When all seven criteria pass, the score is 100.
View lib/wcagAudit.ts on GitHub: https://github.com/musebe/video-accessibility/blob/main/lib/wcagAudit.ts
View lib/wcagAudit.ts on GitHub:
Once a video with spoken narration is loaded and Cloudinary’s transcription pipeline completes, all seven criteria pass simultaneously:
Score: 100 / 100
The score isn’t static. It starts lower when a video is first loaded, before transcription completes, and climbs to 100 as the AI pipeline finishes. This reflects the actual compliance state of the player at any given moment.
Resources:
- Live demo:
Live demo:
- GitHub repository: https://github.com/musebe/video-accessibility
GitHub repository:
- Video Transcription and Summarization
Video Transcription and Summarization
- Upload API — explicit() method
Upload API — explicit() method
- Admin API — Get resource
Admin API — Get resource
- Cloudinary Video Player
Cloudinary Video Player
- Text Tracks (captions, chapters)
Text Tracks (captions, chapters)
WebAIM — Captions, Transcripts, and Audio Descriptions
MDN — WebVTT format









