Skip to content

RESOURCES / BLOG

Achieving 100/100 WCAG 2.1 AA Video Accessibility With Cloudinary AI in Next.js

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.

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:

Criterion Level Requirement Cloudinary Solution
1.2.2 Captions (Prerecorded) A Synchronized captions for all prerecorded audio in video AI transcription → native .vtt discovery
1.2.3 Audio Description or Media Alternative A Text alternative or audio description for video with audio Live synced transcript panel
1.2.5 Audio Description (Prerecorded) AA Audio description of visual content not conveyed by audio Speaker narration confirmed via transcript
2.1.1 Keyboard A All functionality operable via keyboard Cloudinary Video Player (Video.js) built-in
2.2.2 Pause, Stop, Hide A Mechanism to pause or stop moving content No autoplay; pause always visible
1.4.3 Contrast (Minimum) AA 4.5:1 contrast ratio for text High-contrast player theme + caption text-shadow
4.1.2 Name, Role, Value A Accessible name for all UI components aria-label on the video element
Layer Technology
Framework Next.js 15 (App Router, server actions)
Video hosting & AI Cloudinary (SDK v2, Upload API, Admin API)
Video player cloudinary-video-player v4 (Video.js based)
Language TypeScript
Deployment Vercel

The architecture follows a three-stage flow:

Upload → trigger AI (server action) → poll Admin API → re-source player
  1. A video is uploaded to Cloudinary (direct browser upload via unsigned preset).
  2. A Next.js server action calls the Admin API with auto_transcription: true and auto_chaptering: true.
  3. The client polls the Admin API every 4 seconds until the .transcript sidecar file appears.
  4. 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.

// app/actions.ts
export async function triggerTranscriptionAction(publicId: string): Promise<void> {
  await cloudinary.uploader.explicit(publicId, {
    resource_type: 'video',
    type: 'upload',
    auto_transcription: true,
    auto_chaptering: true,
  })
}
Code language: JavaScript (javascript)

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

View app/actions.ts on GitHub: https://github.com/musebe/video-accessibility/blob/main/app/actions.ts

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.

// components/VideoPlayer.tsx — inside buildSource()
if (transReady && !capUrl) {
  opts.textTracks = {
    subtitles: [{
      label:         'English',
      default:       true,
      maxWords:      5,
      wordHighlight: true,
    }],
    options: {
      wordHighlightStyle: { color: '#818cf8' },
    },
  }
  // No URL: player discovers {publicId}.transcript internally
}
Code language: PHP (php)

When a user has corrected the transcript and uploaded an edited VTT, the player switches to that explicit URL instead:

if (capUrl) {
  opts.textTracks = {
    subtitles: [{ label: 'English', default: true, url: capUrl }],
  }
}
Code language: PHP (php)

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

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:

// app/actions.ts
const resource = await cloudinary.api.resource(`${publicId}.transcript`, {
  resource_type: 'raw',
  type: 'upload',
})
// secure_url includes the version: https://res.cloudinary.com/…/v17824…/….transcript
const data = await fetch(resource.secure_url).then(r => r.json())
Code language: JavaScript (javascript)

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

The transcript panel walks the lines array backwards on every time update to find the last spoken line:

// components/TranscriptPanel.tsx
function getActiveIdx(lines: TranscriptLine[], t: number): number {
  for (let i = lines.length - 1; i >= 0; i--) {
    const w = lines[i].words
    if (w.length && w[0].start_time <= t) return i
  }
  return -1
}
Code language: JavaScript (javascript)

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

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:

// components/VideoAccessibilityClient.tsx
const auditResult = runWcagAudit({
  hasAudioDescription: transcriptStatus === 'ready',
  // When .transcript exists and has content, the audio is narrated.
  // Narrated audio describes the visual content, so 1.2.5 passes.
})
Code language: JavaScript (javascript)

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

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:

// components/VideoPlayer.tsx
const discoverChapters = () => {
  const trackList = p.textTracks?.() as TextTrackList | undefined
  if (trackList) {
    for (let i = 0; i < trackList.length; i++) {
      const track = trackList[i]
      if (track.kind === 'chapters' && track.cues?.length) {
        const chaps = []
        for (let j = 0; j < track.cues.length; j++) {
          chaps.push({ time: track.cues[j].startTime, label: track.cues[j].text })
        }
        onChaptersDiscovered?.(chaps)
        return
      }
    }
  }
  setTimeout(discoverChapters, 3000)
}
setTimeout(discoverChapters, 2000)
Code language: JavaScript (javascript)

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

View components/VideoPlayer.tsx on GitHub: https://github.com/musebe/video-accessibility/blob/main/components/VideoPlayer.tsx

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.

Key Action
Space / K Play / Pause
← / → Seek ±5 seconds
↑ / ↓ Volume ±10%
M Toggle mute
F Toggle fullscreen
C Toggle captions
Tab Move between controls

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.

// components/VideoPlayer.tsx
const p = cldVideoPlayer(el, {
  cloud_name:     cloudName,
  controls:       true,    // activates the full keyboard-navigable control bar
  chaptersButton: true,    // adds chapter list, keyboard navigable
  fluid:          true,
})
Code language: JavaScript (javascript)

Read the documentation here: Cloudinary Video Player configuration

View components/VideoPlayer.tsx on GitHub: https://github.com/musebe/video-accessibility/blob/main/components/VideoPlayer.tsx

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.

cldVideoPlayer(el, {
  cloud_name: cloudName,
  controls:   true,
  fluid:      true,
  // autoplay intentionally omitted; player starts paused
})
Code language: JavaScript (javascript)

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:

// components/VideoPlayer.tsx
colors: {
  base:   '#1e293b',  // dark navy control bar background
  accent: '#6366f1',  // indigo scrubber and active elements
  text:   '#f8fafc',  // near-white labels (contrast > 12:1 on base)
}
Code language: JavaScript (javascript)

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:

/* app/globals.css */
.vjs-text-track-cue,
.vjs-text-track-cue > * {
  text-shadow:
    0 1px 5px rgba(0, 0, 0, 0.95),
    0 0 12px rgba(0, 0, 0, 0.85);
}
Code language: CSS (css)

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

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:

// components/VideoPlayer.tsx
<video
  ref={videoRef}
  id="video-player"
  className="cld-video-player cld-video-player-skin-dark"
  aria-label="Cloudinary video player"
/>
Code language: HTML, XML (xml)

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: https://github.com/musebe/video-accessibility/blob/main/components/VideoPlayer.tsx

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.

Preset Background Text Use Case
Default Transparent White General viewing
Subtle 55% black White Busy video frames
Yellow Transparent Yellow Low vision preference
High contrast Black Yellow WCAG AAA caption contrast
Large print Transparent White 1.4× font size
Dark 88% black White Reading-first preference
// components/VideoAccessibilityClient.tsx
<style>{`
  .player-wrapper .vjs-text-track-cue,
  .player-wrapper .vjs-text-track-cue > * {
    color:       ${preset.textColor} !important;
    background:  ${preset.bgColor}   !important;
    font-size:   ${preset.fontSize}  !important;
    text-shadow: 0 1px 5px rgba(0,0,0,0.95) !important;
  }
`}</style>
Code language: HTML, XML (xml)

View components/CaptionStylePicker.tsx on GitHub: https://github.com/musebe/video-accessibility/blob/main/components/CaptionStylePicker.tsx

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.

// lib/wcagAudit.ts
export function runWcagAudit(input: AuditInput): WcagAuditResult {
  const criteria = [
    {
      id: '1.2.2', level: 'A', title: 'Captions (Prerecorded)',
      pass: input.hasCaptions && !!input.captionUrl,
      detail: input.hasCaptions
        ? `Captions provided via ${input.captionUrl}`
        : 'Add a captions track or configure textTracks.',
    },
    {
      id: '1.2.5', level: 'AA', title: 'Audio Description (Prerecorded)',
      pass: input.hasAudioDescription,
      detail: input.hasAudioDescription
        ? 'Speaker narration describes on-screen content.'
        : 'Add an audio description track.',
    },
    // ... 5 more criteria
  ]
  const passed = criteria.filter(c => c.pass).length
  return {
    score:    Math.round((passed / criteria.length) * 100),
    passed,
    total:    criteria.length,
    criteria,
  }
}
Code language: JavaScript (javascript)

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

Task Cloudinary Handles Automatically You Configure
Audio transcription ✅ AI job queued on auto_transcription: true —
WebVTT file generation ✅ .vtt sidecar written to account —
Transcript JSON (word timings) ✅ .transcript sidecar written to account —
Caption discovery in player ✅ Native, no URL needed Set maxWords, wordHighlight
Chapter generation ✅ AI job queued on auto_chaptering: true —
Chapter discovery in player ✅ Via chapters: true in source options Poll textTracks() to read cues
Keyboard controls ✅ Video.js built-in Set controls: true
Pause button ✅ Always present in control bar Omit autoplay
CDN delivery of sidecar files ✅ Versioned secure_url via Admin API Poll Admin API; don’t construct URL manually
Contrast theme — Set colors: { base, accent, text }
aria-label — One attribute on <video>
Audio description (narrated video) — Set hasAudioDescription: transcriptStatus === 'ready'

Once a video with spoken narration is loaded and Cloudinary’s transcription pipeline completes, all seven criteria pass simultaneously:

Criterion Result Detail
1.2.2 — Captions (A) ✅ Pass Captions provided via cld:{publicId}
1.2.3 — Media Alternative (A) ✅ Pass Audio description or transcript present
1.2.5 — Audio Description (AA) ✅ Pass Speaker narration describes on-screen content
2.1.1 — Keyboard (A) ✅ Pass Player is keyboard operable
2.2.2 — Pause, Stop, Hide (A) ✅ Pass Video does not autoplay; pause control available
1.4.3 — Contrast (AA) ✅ Pass Player uses a high-contrast color scheme
4.1.2 — Name, Role, Value (A) ✅ Pass Player labelled: “Cloudinary video player”

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.

Sign up for a free Cloudinary account to get started.

Resources:

Why is video accessibility frequently overlooked, and what are the legal standards teams must target?
Video accessibility is complex because it requires coordinating multiple synchronized streams of content — captions, audio descriptions, keyboard focus maps, and contrast criteria — and most teams stop at simple image alt text. Legally, the Americans with Disabilities Act has applied to web video since 2012, and today regulations like the European Web Accessibility Directive and US Section 508 enforce WCAG 2.1 Level AA as the compliance standard. Failing to provide captions, media alternatives, or keyboard access exposes organizations to high litigation risks and excludes billions of users with hearing, vision, or cognitive differences.
How does Cloudinary automate the hardest WCAG Level A criteria, specifically Captions (1.2.2) and Media Alternatives (1.2.3)?
Cloudinary automates this via its asynchronous AI transcription pipeline. The developer triggers an upload using a Next.js Server Action, and a background job processes the speech track to automatically generate two sidecar raw files in the user account: a standard WebVTT subtitle file and a word-timed JSON file. The Cloudinary Video Player natively discovers these sidecars, so no manual CDN URL generation is required. The Next.js client polls the JSON file via the Admin API, and this file drives a live, interactive, scrolling transcript panel next to the player — satisfying both synchronized captions and media alternatives programmatically.
How can developers satisfy the WCAG 1.2.5 Audio Description (Level AA) requirement without producing an expensive second narration track?
Level AA usually demands a separate audio track that describes visual changes on the screen. However, WCAG 1.2.5 offers an exemption for narrated content: a separate track is not needed if the video is a talking-head tutorial or software walkthrough where the speaker verbally describes everything happening on screen. In this Next.js application, the presence of Cloudinary’s AI-generated transcript serves as programmatic proof of spoken commentary — if the transcript is ready and populated with content, the system confirms the video is narrated, and the audit passes automatically.
How does the application ensure full Keyboard Accessibility (2.1.1) and Pause Controls (2.2.2) at Level A?
Custom video players built from raw HTML often block keyboard users, so this application uses the Cloudinary Video Player, which is built on top of Video.js and provides a complete keyboard interaction model out of the box. Keyboard users can tab through all controls, use the spacebar to play or pause, and use arrow keys to adjust volume and seek through the timeline; the letters M, F, and C toggle mute, fullscreen, and captions. Autoplay is intentionally disabled, so the video always starts in a paused state and users retain full control of the playback experience.
How is the minimum 4.5:1 Contrast Ratio (1.4.3) maintained for player UI elements and captions?
The player configuration uses a high-contrast color scheme — a dark navy control bar, an indigo scrubber, and near-white text labels — producing a contrast ratio exceeding 12:1. Captions pose a unique challenge because video frames change constantly, so the application applies a heavy, multi-layered black text shadow to the caption cues via CSS. This guarantees that white caption text remains readable over bright white or busy video frames without requiring a solid black background box.
Why are AI-generated chapters important for accessibility, and how does the player discover them?
Chapters are not a strict WCAG requirement, but they are a recommended best practice for navigation, helping screen reader and keyboard users jump directly to specific topics in long videos. When the AI transcription runs, it also segments the video into chapters based on topic changes, and these chapters are embedded directly into the player as an internal text track. The application polls the player’s text tracks on startup, and once the chapter cues are found, they populate the player’s keyboard-navigable chapters menu.
What is the difference between lazy and eager transcoding for accessibility assets in Cloudinary?
By default, Cloudinary uses lazy transcoding, where the first user to request the video triggers the transcription process and the subtitle and transcript files are generated on the fly — this can cause a delay of several seconds for the first load. To prevent this, developers can use eager transformations, which instruct Cloudinary to pre-generate all captions, transcripts, and chapters at the moment of upload. When the user loads the page, all accessibility assets are served instantly from the CDN edge cache.

Start Using Cloudinary

Sign up for our free plan and start creating stunning visual experiences in minutes.

Sign Up for Free