{"id":40535,"date":"2026-10-20T07:00:00","date_gmt":"2026-10-20T14:00:00","guid":{"rendered":"https:\/\/cloudinary.com\/blog\/?p=40535"},"modified":"2026-10-23T10:28:08","modified_gmt":"2026-10-23T17:28:08","slug":"wcag-video-accessibility-cloudinary-nextjs","status":"publish","type":"post","link":"https:\/\/cloudinary.com\/blog\/wcag-video-accessibility-cloudinary-nextjs","title":{"rendered":"Achieving 100\/100 WCAG 2.1 AA Video Accessibility With Cloudinary AI in Next.js"},"content":{"rendered":"<div class=\"wp-block-cloudinary-markdown \"><p>Video accessibility is one of the most consistently overlooked areas of web compliance. Most developers know how to add <code>alt<\/code> text for images, but video, captions, transcripts, audio descriptions, and contrast requirements are more complex and the tooling is thinner.<\/p>\n<p>This guide documents how to build a Next.js 15 application that scores <strong>100 out of 100<\/strong> across all seven WCAG 2.1 A\/AA video criteria, with Cloudinary\u2019s AI pipeline handling the hardest requirements. Every step maps directly to a real criterion, with the engine code that satisfies it.<\/p>\n<ul>\n<li>\n<p><strong>Live demo:<\/strong> <a href=\"https:\/\/video-accessibility.vercel.app\/\">https:\/\/video-accessibility.vercel.app\/<\/a><\/p>\n<\/li>\n<li>\n<p><strong>Source:<\/strong> <a href=\"https:\/\/github.com\/musebe\/video-accessibility\">https:\/\/github.com\/musebe\/video-accessibility<\/a><\/p>\n<\/li>\n<\/ul>\n<h2>Video Accessibility is a Requirement<\/h2>\n<p>The Americans with Disabilities Act (ADA) has been applied to websites and web-hosted video since the landmark <em>Netflix v. NAD<\/em> 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.<\/p>\n<p>Beyond compliance, the population that benefits from accessible video is larger than most teams assume:<\/p>\n<ul>\n<li>\n<strong>1.5 billion people<\/strong> worldwide have some degree of <a href=\"https:\/\/www.who.int\/health-topics\/hearing-loss#tab=tab_1\">hearing loss<\/a>.<\/li>\n<li>\n<strong>2.2 billion people<\/strong> have a <a href=\"https:\/\/www.who.int\/news-room\/fact-sheets\/detail\/blindness-and-visual-impairment\">near or distance vision impairment<\/a>.<\/li>\n<li>Captions are regularly used by people watching in noisy environments, non-native speakers, and anyone who processes information better by reading and listening simultaneously.<\/li>\n<\/ul>\n<p><a href=\"https:\/\/www.w3.org\/TR\/WCAG21\/\">WCAG 2.1<\/a>, 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).<\/p>\n<h2>The 7 WCAG 2.1 Criteria for Video<\/h2>\n<p>This project targets every video-specific criterion at Level A and AA. Here\u2019s the complete set, what each requires, and which Cloudinary feature satisfies it:<\/p>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Criterion<\/th>\n<th>Level<\/th>\n<th>Requirement<\/th>\n<th>Cloudinary Solution<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td><a href=\"https:\/\/www.w3.org\/WAI\/WCAG21\/Understanding\/captions-prerecorded\">1.2.2 Captions (Prerecorded)<\/a><\/td>\n<td>A<\/td>\n<td>Synchronized captions for all prerecorded audio in video<\/td>\n<td>AI transcription \u2192 native <code>.vtt<\/code> discovery<\/td>\n<\/tr>\n<tr>\n<td><a href=\"https:\/\/www.w3.org\/WAI\/WCAG21\/Understanding\/audio-description-or-media-alternative-prerecorded\">1.2.3 Audio Description or Media Alternative<\/a><\/td>\n<td>A<\/td>\n<td>Text alternative or audio description for video with audio<\/td>\n<td>Live synced transcript panel<\/td>\n<\/tr>\n<tr>\n<td><a href=\"https:\/\/www.w3.org\/WAI\/WCAG21\/Understanding\/audio-description-prerecorded\">1.2.5 Audio Description (Prerecorded)<\/a><\/td>\n<td>AA<\/td>\n<td>Audio description of visual content not conveyed by audio<\/td>\n<td>Speaker narration confirmed via transcript<\/td>\n<\/tr>\n<tr>\n<td><a href=\"https:\/\/www.w3.org\/WAI\/WCAG21\/Understanding\/keyboard\">2.1.1 Keyboard<\/a><\/td>\n<td>A<\/td>\n<td>All functionality operable via keyboard<\/td>\n<td>Cloudinary Video Player (Video.js) built-in<\/td>\n<\/tr>\n<tr>\n<td><a href=\"https:\/\/www.w3.org\/WAI\/WCAG21\/Understanding\/pause-stop-hide\">2.2.2 Pause, Stop, Hide<\/a><\/td>\n<td>A<\/td>\n<td>Mechanism to pause or stop moving content<\/td>\n<td>No autoplay; pause always visible<\/td>\n<\/tr>\n<tr>\n<td><a href=\"https:\/\/www.w3.org\/WAI\/WCAG21\/Understanding\/contrast-minimum\">1.4.3 Contrast (Minimum)<\/a><\/td>\n<td>AA<\/td>\n<td>4.5:1 contrast ratio for text<\/td>\n<td>High-contrast player theme + caption text-shadow<\/td>\n<\/tr>\n<tr>\n<td><a href=\"https:\/\/www.w3.org\/WAI\/WCAG21\/Understanding\/name-role-value\">4.1.2 Name, Role, Value<\/a><\/td>\n<td>A<\/td>\n<td>Accessible name for all UI components<\/td>\n<td><code>aria-label<\/code> on the video element<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<h2>Project Stack Overview<\/h2>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Layer<\/th>\n<th>Technology<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Framework<\/td>\n<td>Next.js 15 (App Router, server actions)<\/td>\n<\/tr>\n<tr>\n<td>Video hosting &amp; AI<\/td>\n<td>Cloudinary (SDK v2, Upload API, Admin API)<\/td>\n<\/tr>\n<tr>\n<td>Video player<\/td>\n<td>cloudinary-video-player v4 (Video.js based)<\/td>\n<\/tr>\n<tr>\n<td>Language<\/td>\n<td>TypeScript<\/td>\n<\/tr>\n<tr>\n<td>Deployment<\/td>\n<td>Vercel<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<p>The architecture follows a three-stage flow:<\/p>\n<pre class=\"js-syntax-highlighted\"><code>Upload \u2192 trigger AI (server action) \u2192 poll Admin API \u2192 re-source player\n<\/code><\/pre>\n<ol>\n<li>A video is uploaded to Cloudinary (direct browser upload via unsigned preset).<\/li>\n<li>A Next.js server action calls the Admin API with <code>auto_transcription: true<\/code> and <code>auto_chaptering: true<\/code>.<\/li>\n<li>The client polls the Admin API every 4 seconds until the <code>.transcript<\/code> sidecar file appears.<\/li>\n<li>The player is re-sourced with native caption discovery enabled; the transcript and chapters are served directly from Cloudinary\u2019s CDN.<\/li>\n<\/ol>\n<p>There isn\u2019t a manual WebVTT authoring step anywhere in this flow.<\/p>\n<h2>Step 1: Trigger AI Transcription (WCAG 1.2.2 Captions, Level A)<\/h2>\n<p><strong>What the criterion requires:<\/strong><\/p>\n<p>Every prerecorded video with an audio track must have a synchronized caption track, text displayed in sync with the spoken audio.<\/p>\n<p><strong>How Cloudinary handles it:<\/strong><\/p>\n<p>Cloudinary\u2019s <a href=\"https:\/\/cloudinary.com\/documentation\/video_transcription\">Video Transcription add-on<\/a> accepts one API call and returns two sidecar files:<\/p>\n<ul>\n<li>\n<code>{publicId}.transcript<\/code>: a word-timed JSON file with start\/end timestamps per word<\/li>\n<li>\n<code>{publicId}.vtt<\/code>: a standard WebVTT file<\/li>\n<\/ul>\n<p>Both files live alongside the video in your Cloudinary account under the same <code>public_id<\/code> namespace. The Cloudinary Video Player can discover them natively, with no explicit URL required.<\/p>\n<h3>The Engine<\/h3>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-1\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ app\/actions.ts<\/span>\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-keyword\">async<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">triggerTranscriptionAction<\/span>(<span class=\"hljs-params\">publicId: string<\/span>): <span class=\"hljs-title\">Promise<\/span>&lt;<span class=\"hljs-title\">void<\/span>&gt; <\/span>{\n  <span class=\"hljs-keyword\">await<\/span> cloudinary.uploader.explicit(publicId, {\n    <span class=\"hljs-attr\">resource_type<\/span>: <span class=\"hljs-string\">'video'<\/span>,\n    <span class=\"hljs-attr\">type<\/span>: <span class=\"hljs-string\">'upload'<\/span>,\n    <span class=\"hljs-attr\">auto_transcription<\/span>: <span class=\"hljs-literal\">true<\/span>,\n    <span class=\"hljs-attr\">auto_chaptering<\/span>: <span class=\"hljs-literal\">true<\/span>,\n  })\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-1\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>With one call, Cloudinary queues the AI job asynchronously and writes the sidecar files when complete.<\/p>\n<blockquote>\n<p>Read the documentation here: <a href=\"https:\/\/cloudinary.com\/documentation\/image_upload_api_reference#explicit_method\">uploader.explicit() reference<\/a> \u00b7 <a href=\"https:\/\/cloudinary.com\/documentation\/video_transcription\">Video transcription overview<\/a><\/p>\n<\/blockquote>\n<blockquote>\n<p>View <code>app\/actions.ts<\/code> on GitHub: <a href=\"https:\/\/github.com\/musebe\/video-accessibility\/blob\/main\/app\/actions.ts\">https:\/\/github.com\/musebe\/video-accessibility\/blob\/main\/app\/actions.ts<\/a><\/p>\n<\/blockquote>\n<h2>Step 2: Paced Word-Highlighted Subtitles<\/h2>\n<h3>Native Discovery<\/h3>\n<p>Most caption integrations require you to construct a CDN URL for the VTT file and pass it as a <code>&lt;track src=&quot;...&quot;&gt;<\/code>. With Cloudinary, the player knows the account\u2019s cloud name and the video\u2019s <code>public_id<\/code>, so it can find the <code>.transcript<\/code> JSON automatically.<\/p>\n<p>Setting <code>maxWords: 5<\/code> 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\u2019s spoken (karaoke style). This is significantly more readable for users with cognitive disabilities or reading difficulties.<\/p>\n<h3>The Engine<\/h3>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-2\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ components\/VideoPlayer.tsx \u2014 inside buildSource()<\/span>\n<span class=\"hljs-keyword\">if<\/span> (transReady &amp;&amp; !capUrl) {\n  opts.textTracks = {\n    subtitles: &#91;{\n      label:         <span class=\"hljs-string\">'English'<\/span>,\n      <span class=\"hljs-keyword\">default<\/span>:       <span class=\"hljs-keyword\">true<\/span>,\n      maxWords:      <span class=\"hljs-number\">5<\/span>,\n      wordHighlight: <span class=\"hljs-keyword\">true<\/span>,\n    }],\n    options: {\n      wordHighlightStyle: { color: <span class=\"hljs-string\">'#818cf8'<\/span> },\n    },\n  }\n  <span class=\"hljs-comment\">\/\/ No URL: player discovers {publicId}.transcript internally<\/span>\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-2\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PHP<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">php<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>When a user has corrected the transcript and uploaded an edited VTT, the player switches to that explicit URL instead:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-3\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php shcb-wrap-lines\"><span class=\"hljs-keyword\">if<\/span> (capUrl) {\n  opts.textTracks = {\n    subtitles: &#91;{ label: <span class=\"hljs-string\">'English'<\/span>, <span class=\"hljs-keyword\">default<\/span>: <span class=\"hljs-keyword\">true<\/span>, url: capUrl }],\n  }\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-3\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PHP<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">php<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<blockquote>\n<p>Read the documentation here: <a href=\"https:\/\/cloudinary.com\/documentation\/cloudinary_video_player#text_tracks\">Cloudinary Video Player \u2014 Text Tracks<\/a><\/p>\n<\/blockquote>\n<blockquote>\n<p>View <code>components\/VideoPlayer.tsx<\/code> on GitHub: <a href=\"https:\/\/github.com\/musebe\/video-accessibility\/blob\/main\/components\/VideoPlayer.tsx\">https:\/\/github.com\/musebe\/video-accessibility\/blob\/main\/components\/VideoPlayer.tsx<\/a><\/p>\n<\/blockquote>\n<h2>Step 3: Live Synced Transcript Panel (WCAG 1.2.3, Level A)<\/h2>\n<p><strong>What the criterion requires:<\/strong><\/p>\n<p>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.<\/p>\n<p>A transcript file sitting in storage does not satisfy this requirement. It must be presented to the user in a meaningful, accessible way.<\/p>\n<p><strong>How Cloudinary handles it:<\/strong><\/p>\n<p>The same <code>.transcript<\/code> 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.<\/p>\n<p><strong>How the Admin API poll works:<\/strong><\/p>\n<p>Because Cloudinary processes transcription asynchronously, the app polls for the file every four seconds using the Admin API, which returns a versioned <code>secure_url<\/code> for raw sidecar files:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-4\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ app\/actions.ts<\/span>\n<span class=\"hljs-keyword\">const<\/span> resource = <span class=\"hljs-keyword\">await<\/span> cloudinary.api.resource(<span class=\"hljs-string\">`<span class=\"hljs-subst\">${publicId}<\/span>.transcript`<\/span>, {\n  <span class=\"hljs-attr\">resource_type<\/span>: <span class=\"hljs-string\">'raw'<\/span>,\n  <span class=\"hljs-attr\">type<\/span>: <span class=\"hljs-string\">'upload'<\/span>,\n})\n<span class=\"hljs-comment\">\/\/ secure_url includes the version: https:\/\/res.cloudinary.com\/\u2026\/v17824\u2026\/\u2026.transcript<\/span>\n<span class=\"hljs-keyword\">const<\/span> data = <span class=\"hljs-keyword\">await<\/span> fetch(resource.secure_url).then(<span class=\"hljs-function\"><span class=\"hljs-params\">r<\/span> =&gt;<\/span> r.json())\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-4\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>The version number in the URL is required; CDN URLs for raw files on this account type do not resolve without it.<\/p>\n<blockquote>\n<p>Read the documentation here: <a href=\"https:\/\/cloudinary.com\/documentation\/admin_api#get_resources\">Admin API \u2014 Get resource<\/a><\/p>\n<\/blockquote>\n<h3>The Sync Engine<\/h3>\n<p>The transcript panel walks the lines array backwards on every time update to find the last spoken line:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-5\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ components\/TranscriptPanel.tsx<\/span>\n<span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">getActiveIdx<\/span>(<span class=\"hljs-params\">lines: TranscriptLine&#91;], t: number<\/span>): <span class=\"hljs-title\">number<\/span> <\/span>{\n  <span class=\"hljs-keyword\">for<\/span> (<span class=\"hljs-keyword\">let<\/span> i = lines.length - <span class=\"hljs-number\">1<\/span>; i &gt;= <span class=\"hljs-number\">0<\/span>; i--) {\n    <span class=\"hljs-keyword\">const<\/span> w = lines&#91;i].words\n    <span class=\"hljs-keyword\">if<\/span> (w.length &amp;&amp; w&#91;<span class=\"hljs-number\">0<\/span>].start_time &lt;= t) <span class=\"hljs-keyword\">return<\/span> i\n  }\n  <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-number\">-1<\/span>\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-5\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>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.<\/p>\n<blockquote>\n<p>View <code>components\/TranscriptPanel.tsx<\/code> on GitHub: <a href=\"https:\/\/github.com\/musebe\/video-accessibility\/blob\/main\/components\/TranscriptPanel.tsx\">https:\/\/github.com\/musebe\/video-accessibility\/blob\/main\/components\/TranscriptPanel.tsx<\/a><\/p>\n<\/blockquote>\n<h2>Step 4: Audio Description via Speaker Narration (WCAG 1.2.5, Level AA)<\/h2>\n<p><strong>What the criterion requires:<\/strong><\/p>\n<p>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.<\/p>\n<h3>The WCAG Exemption for Narrated Content<\/h3>\n<p>WCAG 1.2.5 includes a recognized exemption. When <strong>all visual content is already described by the existing audio track<\/strong>, a separate audio description isn\u2019t needed. A talking-head tutorial, screenshare walkthrough, or product demo where the speaker narrates what\u2019s on screen qualifies.<\/p>\n<p><strong>How Cloudinary confirms it:<\/strong><\/p>\n<p>Cloudinary\u2019s 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.<\/p>\n<p>The application uses transcription completion as the confirmation signal:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-6\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ components\/VideoAccessibilityClient.tsx<\/span>\n<span class=\"hljs-keyword\">const<\/span> auditResult = runWcagAudit({\n  <span class=\"hljs-attr\">hasAudioDescription<\/span>: transcriptStatus === <span class=\"hljs-string\">'ready'<\/span>,\n  <span class=\"hljs-comment\">\/\/ When .transcript exists and has content, the audio is narrated.<\/span>\n  <span class=\"hljs-comment\">\/\/ Narrated audio describes the visual content, so 1.2.5 passes.<\/span>\n})\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-6\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>The audit detail reads: <em>\u201cSpeaker narration describes on-screen content \u2014 audio description satisfied by spoken commentary.\u201d<\/em><\/p>\n<blockquote>\n<p>View <code>components\/VideoAccessibilityClient.tsx<\/code> on GitHub: <a href=\"https:\/\/github.com\/musebe\/video-accessibility\/blob\/main\/components\/VideoAccessibilityClient.tsx\">https:\/\/github.com\/musebe\/video-accessibility\/blob\/main\/components\/VideoAccessibilityClient.tsx<\/a><\/p>\n<\/blockquote>\n<h2>Step 5: AI Chaptering and Player Discovery<\/h2>\n<p>Chapters aren\u2019t a hard requirement of WCAG by themselves, but they improve navigation for keyboard and screen reader users, especially for longer videos. They\u2019re a WCAG best practice under Guideline 2.4 (Navigable).<\/p>\n<p><strong>How Cloudinary generates chapters:<\/strong><\/p>\n<p>The same <code>uploader.explicit()<\/code> call that triggers transcription also triggers AI chaptering when <code>auto_chaptering: true<\/code> is set. Cloudinary\u2019s model segments the video by topic and writes chapter markers as part of the player\u2019s internal chapter track.<\/p>\n<p><strong>How the player discovers chapters:<\/strong><\/p>\n<p>The chapters aren\u2019t returned as a raw CDN file; they live as an internal text track within the player. The application polls <code>p.textTracks()<\/code> every three seconds after player init:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-7\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ components\/VideoPlayer.tsx<\/span>\n<span class=\"hljs-keyword\">const<\/span> discoverChapters = <span class=\"hljs-function\"><span class=\"hljs-params\">()<\/span> =&gt;<\/span> {\n  <span class=\"hljs-keyword\">const<\/span> trackList = p.textTracks?.() <span class=\"hljs-keyword\">as<\/span> TextTrackList | <span class=\"hljs-literal\">undefined<\/span>\n  <span class=\"hljs-keyword\">if<\/span> (trackList) {\n    <span class=\"hljs-keyword\">for<\/span> (<span class=\"hljs-keyword\">let<\/span> i = <span class=\"hljs-number\">0<\/span>; i &lt; trackList.length; i++) {\n      <span class=\"hljs-keyword\">const<\/span> track = trackList&#91;i]\n      <span class=\"hljs-keyword\">if<\/span> (track.kind === <span class=\"hljs-string\">'chapters'<\/span> &amp;&amp; track.cues?.length) {\n        <span class=\"hljs-keyword\">const<\/span> chaps = &#91;]\n        <span class=\"hljs-keyword\">for<\/span> (<span class=\"hljs-keyword\">let<\/span> j = <span class=\"hljs-number\">0<\/span>; j &lt; track.cues.length; j++) {\n          chaps.push({ <span class=\"hljs-attr\">time<\/span>: track.cues&#91;j].startTime, <span class=\"hljs-attr\">label<\/span>: track.cues&#91;j].text })\n        }\n        onChaptersDiscovered?.(chaps)\n        <span class=\"hljs-keyword\">return<\/span>\n      }\n    }\n  }\n  setTimeout(discoverChapters, <span class=\"hljs-number\">3000<\/span>)\n}\nsetTimeout(discoverChapters, <span class=\"hljs-number\">2000<\/span>)\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-7\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>Once chapters are found, they\u2019re passed up to the parent via <code>onChaptersDiscovered<\/code> and reflected in the player\u2019s built-in chapters menu button (<code>chaptersButton: true<\/code>).<\/p>\n<blockquote>\n<p>Read the documentation here: <a href=\"https:\/\/cloudinary.com\/documentation\/cloudinary_video_player#chapters\">Cloudinary Video Player \u2014 Chapters<\/a><\/p>\n<\/blockquote>\n<blockquote>\n<p>View <code>components\/VideoPlayer.tsx<\/code> on GitHub: <a href=\"https:\/\/github.com\/musebe\/video-accessibility\/blob\/main\/components\/VideoPlayer.tsx\">https:\/\/github.com\/musebe\/video-accessibility\/blob\/main\/components\/VideoPlayer.tsx<\/a><\/p>\n<\/blockquote>\n<h2>Step 6: Keyboard Accessibility (WCAG 2.1.1, Level A)<\/h2>\n<p><strong>What the criterion requires:<\/strong><\/p>\n<p>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.<\/p>\n<p><strong>How Cloudinary handles it:<\/strong><\/p>\n<p>The Cloudinary Video Player is built on <a href=\"https:\/\/videojs.com\/\">Video.js<\/a>, which ships with a complete keyboard interaction model out of the box. No additional code is required.<\/p>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Key<\/th>\n<th>Action<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td><code>Space<\/code> \/ <code>K<\/code><\/td>\n<td>Play \/ Pause<\/td>\n<\/tr>\n<tr>\n<td><code>\u2190<\/code> \/ <code>\u2192<\/code><\/td>\n<td>Seek \u00b15 seconds<\/td>\n<\/tr>\n<tr>\n<td><code>\u2191<\/code> \/ <code>\u2193<\/code><\/td>\n<td>Volume \u00b110%<\/td>\n<\/tr>\n<tr>\n<td><code>M<\/code><\/td>\n<td>Toggle mute<\/td>\n<\/tr>\n<tr>\n<td><code>F<\/code><\/td>\n<td>Toggle fullscreen<\/td>\n<\/tr>\n<tr>\n<td><code>C<\/code><\/td>\n<td>Toggle captions<\/td>\n<\/tr>\n<tr>\n<td><code>Tab<\/code><\/td>\n<td>Move between controls<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<p>The chapters button, caption style menu, and all player controls are fully tab-navigable. Setting <code>controls: true<\/code> in the player config is all that is needed.<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-8\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ components\/VideoPlayer.tsx<\/span>\n<span class=\"hljs-keyword\">const<\/span> p = cldVideoPlayer(el, {\n  <span class=\"hljs-attr\">cloud_name<\/span>:     cloudName,\n  <span class=\"hljs-attr\">controls<\/span>:       <span class=\"hljs-literal\">true<\/span>,    <span class=\"hljs-comment\">\/\/ activates the full keyboard-navigable control bar<\/span>\n  <span class=\"hljs-attr\">chaptersButton<\/span>: <span class=\"hljs-literal\">true<\/span>,    <span class=\"hljs-comment\">\/\/ adds chapter list, keyboard navigable<\/span>\n  <span class=\"hljs-attr\">fluid<\/span>:          <span class=\"hljs-literal\">true<\/span>,\n})\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-8\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<blockquote>\n<p>Read the documentation here: <a href=\"https:\/\/cloudinary.com\/documentation\/cloudinary_video_player#player_configuration\">Cloudinary Video Player configuration<\/a><\/p>\n<\/blockquote>\n<blockquote>\n<p>View <code>components\/VideoPlayer.tsx<\/code> on GitHub: <a href=\"https:\/\/github.com\/musebe\/video-accessibility\/blob\/main\/components\/VideoPlayer.tsx\">https:\/\/github.com\/musebe\/video-accessibility\/blob\/main\/components\/VideoPlayer.tsx<\/a><\/p>\n<\/blockquote>\n<h2>Step 7: No Autoplay (WCAG 2.2.2, Level A)<\/h2>\n<p><strong>What the criterion requires:<\/strong><\/p>\n<p>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.<\/p>\n<p><strong>How Cloudinary handles it:<\/strong><\/p>\n<p>The Cloudinary Video Player doesn\u2019t autoplay by default. The <code>autoplay<\/code> 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.<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-9\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\">cldVideoPlayer(el, {\n  <span class=\"hljs-attr\">cloud_name<\/span>: cloudName,\n  <span class=\"hljs-attr\">controls<\/span>:   <span class=\"hljs-literal\">true<\/span>,\n  <span class=\"hljs-attr\">fluid<\/span>:      <span class=\"hljs-literal\">true<\/span>,\n  <span class=\"hljs-comment\">\/\/ autoplay intentionally omitted; player starts paused<\/span>\n})\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-9\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>This single configuration omission satisfies the criterion for all videos in the application.<\/p>\n<h2>Step 8: High-Contrast Player Theme (WCAG 1.4.3, Level AA)<\/h2>\n<p><strong>What the criterion requires:<\/strong><\/p>\n<p>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.<\/p>\n<p>Two things working together:<\/p>\n<p><strong>1. Player color theme<\/strong><\/p>\n<p>The Cloudinary Video Player accepts a <code>colors<\/code> object that controls the control bar:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-10\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ components\/VideoPlayer.tsx<\/span>\n<span class=\"hljs-attr\">colors<\/span>: {\n  <span class=\"hljs-attr\">base<\/span>:   <span class=\"hljs-string\">'#1e293b'<\/span>,  <span class=\"hljs-comment\">\/\/ dark navy control bar background<\/span>\n  <span class=\"hljs-attr\">accent<\/span>: <span class=\"hljs-string\">'#6366f1'<\/span>,  <span class=\"hljs-comment\">\/\/ indigo scrubber and active elements<\/span>\n  <span class=\"hljs-attr\">text<\/span>:   <span class=\"hljs-string\">'#f8fafc'<\/span>,  <span class=\"hljs-comment\">\/\/ near-white labels (contrast &gt; 12:1 on base)<\/span>\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-10\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p><strong>2. Caption text-shadow for video overlay<\/strong><\/p>\n<p>Caption text appears over unpredictable video content. Rather than requiring a black background box, a CSS <code>text-shadow<\/code> ensures legibility over any frame:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-11\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css shcb-wrap-lines\"><span class=\"hljs-comment\">\/* app\/globals.css *\/<\/span>\n<span class=\"hljs-selector-class\">.vjs-text-track-cue<\/span>,\n<span class=\"hljs-selector-class\">.vjs-text-track-cue<\/span> &gt; * {\n  <span class=\"hljs-attribute\">text-shadow<\/span>:\n    <span class=\"hljs-number\">0<\/span> <span class=\"hljs-number\">1px<\/span> <span class=\"hljs-number\">5px<\/span> <span class=\"hljs-built_in\">rgba<\/span>(<span class=\"hljs-number\">0<\/span>, <span class=\"hljs-number\">0<\/span>, <span class=\"hljs-number\">0<\/span>, <span class=\"hljs-number\">0.95<\/span>),\n    <span class=\"hljs-number\">0<\/span> <span class=\"hljs-number\">0<\/span> <span class=\"hljs-number\">12px<\/span> <span class=\"hljs-built_in\">rgba<\/span>(<span class=\"hljs-number\">0<\/span>, <span class=\"hljs-number\">0<\/span>, <span class=\"hljs-number\">0<\/span>, <span class=\"hljs-number\">0.85<\/span>);\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-11\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>This approach keeps captions visually clean while maintaining WCAG-required contrast in all conditions.<\/p>\n<blockquote>\n<p>View <code>app\/globals.css<\/code> on GitHub: <a href=\"https:\/\/github.com\/musebe\/video-accessibility\/blob\/main\/app\/globals.css\">https:\/\/github.com\/musebe\/video-accessibility\/blob\/main\/app\/globals.css<\/a><\/p>\n<\/blockquote>\n<h2>Step 9: Accessible Video Label (WCAG 4.1.2, Level A)<\/h2>\n<p><strong>What the criterion requires:<\/strong><\/p>\n<p>Every user interface component must have a programmatically determinable name that assistive technologies can read aloud. A <code>&lt;video&gt;<\/code> element with no label is announced by screen readers simply as \u201cvideo\u201d, which is ambiguous and unhelpful, especially when multiple videos appear on a page.<\/p>\n<h3>The Engine<\/h3>\n<p>One attribute on the native video element:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-12\" data-shcb-language-name=\"HTML, XML\" data-shcb-language-slug=\"xml\"><span><code class=\"hljs language-xml shcb-wrap-lines\">\/\/ components\/VideoPlayer.tsx\n<span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">video<\/span>\n  <span class=\"hljs-attr\">ref<\/span>=<span class=\"hljs-string\">{videoRef}<\/span>\n  <span class=\"hljs-attr\">id<\/span>=<span class=\"hljs-string\">\"video-player\"<\/span>\n  <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"cld-video-player cld-video-player-skin-dark\"<\/span>\n  <span class=\"hljs-attr\">aria-label<\/span>=<span class=\"hljs-string\">\"Cloudinary video player\"<\/span>\n\/&gt;<\/span>\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-12\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">HTML, XML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">xml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>Because the Cloudinary Video Player mounts on top of a standard <code>&lt;video&gt;<\/code> element that we control, the <code>aria-label<\/code> propagates through the player\u2019s accessible DOM tree. In a production application this label should describe the video\u2019s actual content (e.g., <code>&quot;Introduction to Cloudinary Transformations&quot;<\/code>).<\/p>\n<blockquote>\n<p>View <code>components\/VideoPlayer.tsx<\/code> on GitHub: <a href=\"https:\/\/github.com\/musebe\/video-accessibility\/blob\/main\/components\/VideoPlayer.tsx\">https:\/\/github.com\/musebe\/video-accessibility\/blob\/main\/components\/VideoPlayer.tsx<\/a><\/p>\n<\/blockquote>\n<h2>Step 10: Caption Style Picker for User Preference<\/h2>\n<p>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 <code>&lt;style&gt;<\/code> block with no re-sourcing of the player required.<\/p>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Preset<\/th>\n<th>Background<\/th>\n<th>Text<\/th>\n<th>Use Case<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Default<\/td>\n<td>Transparent<\/td>\n<td>White<\/td>\n<td>General viewing<\/td>\n<\/tr>\n<tr>\n<td>Subtle<\/td>\n<td>55% black<\/td>\n<td>White<\/td>\n<td>Busy video frames<\/td>\n<\/tr>\n<tr>\n<td>Yellow<\/td>\n<td>Transparent<\/td>\n<td>Yellow<\/td>\n<td>Low vision preference<\/td>\n<\/tr>\n<tr>\n<td>High contrast<\/td>\n<td>Black<\/td>\n<td>Yellow<\/td>\n<td>WCAG AAA caption contrast<\/td>\n<\/tr>\n<tr>\n<td>Large print<\/td>\n<td>Transparent<\/td>\n<td>White<\/td>\n<td>1.4\u00d7 font size<\/td>\n<\/tr>\n<tr>\n<td>Dark<\/td>\n<td>88% black<\/td>\n<td>White<\/td>\n<td>Reading-first preference<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-13\" data-shcb-language-name=\"HTML, XML\" data-shcb-language-slug=\"xml\"><span><code class=\"hljs language-xml shcb-wrap-lines\">\/\/ components\/VideoAccessibilityClient.tsx\n<span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">style<\/span>&gt;<\/span>{`\n  .player-wrapper .vjs-text-track-cue,\n  .player-wrapper .vjs-text-track-cue &gt; * {\n    color:       ${preset.textColor} !important;\n    background:  ${preset.bgColor}   !important;\n    font-size:   ${preset.fontSize}  !important;\n    text-shadow: 0 1px 5px rgba(0,0,0,0.95) !important;\n  }\n`}<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">style<\/span>&gt;<\/span>\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-13\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">HTML, XML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">xml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<blockquote>\n<p>View <code>components\/CaptionStylePicker.tsx<\/code> on GitHub: <a href=\"https:\/\/github.com\/musebe\/video-accessibility\/blob\/main\/components\/CaptionStylePicker.tsx\">https:\/\/github.com\/musebe\/video-accessibility\/blob\/main\/components\/CaptionStylePicker.tsx<\/a><\/p>\n<\/blockquote>\n<h2>Step 11: The WCAG Self-Audit Function<\/h2>\n<p>The application includes a <code>runWcagAudit()<\/code> 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.<\/p>\n<p>So, why runtime audit instead of static lint?<\/p>\n<p>Some criteria can\u2019t 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.<\/p>\n<h3>The Engine Structure<\/h3>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-14\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ lib\/wcagAudit.ts<\/span>\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">runWcagAudit<\/span>(<span class=\"hljs-params\">input: AuditInput<\/span>): <span class=\"hljs-title\">WcagAuditResult<\/span> <\/span>{\n  <span class=\"hljs-keyword\">const<\/span> criteria = &#91;\n    {\n      <span class=\"hljs-attr\">id<\/span>: <span class=\"hljs-string\">'1.2.2'<\/span>, <span class=\"hljs-attr\">level<\/span>: <span class=\"hljs-string\">'A'<\/span>, <span class=\"hljs-attr\">title<\/span>: <span class=\"hljs-string\">'Captions (Prerecorded)'<\/span>,\n      <span class=\"hljs-attr\">pass<\/span>: input.hasCaptions &amp;&amp; !!input.captionUrl,\n      <span class=\"hljs-attr\">detail<\/span>: input.hasCaptions\n        ? <span class=\"hljs-string\">`Captions provided via <span class=\"hljs-subst\">${input.captionUrl}<\/span>`<\/span>\n        : <span class=\"hljs-string\">'Add a captions track or configure textTracks.'<\/span>,\n    },\n    {\n      <span class=\"hljs-attr\">id<\/span>: <span class=\"hljs-string\">'1.2.5'<\/span>, <span class=\"hljs-attr\">level<\/span>: <span class=\"hljs-string\">'AA'<\/span>, <span class=\"hljs-attr\">title<\/span>: <span class=\"hljs-string\">'Audio Description (Prerecorded)'<\/span>,\n      <span class=\"hljs-attr\">pass<\/span>: input.hasAudioDescription,\n      <span class=\"hljs-attr\">detail<\/span>: input.hasAudioDescription\n        ? <span class=\"hljs-string\">'Speaker narration describes on-screen content.'<\/span>\n        : <span class=\"hljs-string\">'Add an audio description track.'<\/span>,\n    },\n    <span class=\"hljs-comment\">\/\/ ... 5 more criteria<\/span>\n  ]\n  <span class=\"hljs-keyword\">const<\/span> passed = criteria.filter(<span class=\"hljs-function\"><span class=\"hljs-params\">c<\/span> =&gt;<\/span> c.pass).length\n  <span class=\"hljs-keyword\">return<\/span> {\n    <span class=\"hljs-attr\">score<\/span>:    <span class=\"hljs-built_in\">Math<\/span>.round((passed \/ criteria.length) * <span class=\"hljs-number\">100<\/span>),\n    passed,\n    <span class=\"hljs-attr\">total<\/span>:    criteria.length,\n    criteria,\n  }\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-14\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>The score is a percentage: <code>passed \/ total \u00d7 100<\/code>. When all seven criteria pass, the score is 100.<\/p>\n<blockquote>\n<p>View <code>lib\/wcagAudit.ts<\/code> on GitHub: <a href=\"https:\/\/github.com\/musebe\/video-accessibility\/blob\/main\/lib\/wcagAudit.ts\">https:\/\/github.com\/musebe\/video-accessibility\/blob\/main\/lib\/wcagAudit.ts<\/a><\/p>\n<\/blockquote>\n<h2>What Cloudinary Handles vs. What You Configure<\/h2>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Task<\/th>\n<th>Cloudinary Handles Automatically<\/th>\n<th>You Configure<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Audio transcription<\/td>\n<td>\u2705 AI job queued on <code>auto_transcription: true<\/code><\/td>\n<td>\u2014<\/td>\n<\/tr>\n<tr>\n<td>WebVTT file generation<\/td>\n<td>\u2705 <code>.vtt<\/code> sidecar written to account<\/td>\n<td>\u2014<\/td>\n<\/tr>\n<tr>\n<td>Transcript JSON (word timings)<\/td>\n<td>\u2705 <code>.transcript<\/code> sidecar written to account<\/td>\n<td>\u2014<\/td>\n<\/tr>\n<tr>\n<td>Caption discovery in player<\/td>\n<td>\u2705 Native, no URL needed<\/td>\n<td>Set <code>maxWords<\/code>, <code>wordHighlight<\/code><\/td>\n<\/tr>\n<tr>\n<td>Chapter generation<\/td>\n<td>\u2705 AI job queued on <code>auto_chaptering: true<\/code><\/td>\n<td>\u2014<\/td>\n<\/tr>\n<tr>\n<td>Chapter discovery in player<\/td>\n<td>\u2705 Via <code>chapters: true<\/code> in source options<\/td>\n<td>Poll <code>textTracks()<\/code> to read cues<\/td>\n<\/tr>\n<tr>\n<td>Keyboard controls<\/td>\n<td>\u2705 Video.js built-in<\/td>\n<td>Set <code>controls: true<\/code><\/td>\n<\/tr>\n<tr>\n<td>Pause button<\/td>\n<td>\u2705 Always present in control bar<\/td>\n<td>Omit <code>autoplay<\/code><\/td>\n<\/tr>\n<tr>\n<td>CDN delivery of sidecar files<\/td>\n<td>\u2705 Versioned <code>secure_url<\/code> via Admin API<\/td>\n<td>Poll Admin API; don\u2019t construct URL manually<\/td>\n<\/tr>\n<tr>\n<td>Contrast theme<\/td>\n<td>\u2014<\/td>\n<td>Set <code>colors: { base, accent, text }<\/code><\/td>\n<\/tr>\n<tr>\n<td><code>aria-label<\/code><\/td>\n<td>\u2014<\/td>\n<td>One attribute on <code>&lt;video&gt;<\/code><\/td>\n<\/tr>\n<tr>\n<td>Audio description (narrated video)<\/td>\n<td>\u2014<\/td>\n<td>Set <code>hasAudioDescription: transcriptStatus === 'ready'<\/code><\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<h2>Results: 100\/100<\/h2>\n<p>Once a video with spoken narration is loaded and Cloudinary\u2019s transcription pipeline completes, all seven criteria pass simultaneously:<\/p>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Criterion<\/th>\n<th>Result<\/th>\n<th>Detail<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>1.2.2 \u2014 Captions (A)<\/td>\n<td>\u2705 Pass<\/td>\n<td>Captions provided via <code>cld:{publicId}<\/code><\/td>\n<\/tr>\n<tr>\n<td>1.2.3 \u2014 Media Alternative (A)<\/td>\n<td>\u2705 Pass<\/td>\n<td>Audio description or transcript present<\/td>\n<\/tr>\n<tr>\n<td>1.2.5 \u2014 Audio Description (AA)<\/td>\n<td>\u2705 Pass<\/td>\n<td>Speaker narration describes on-screen content<\/td>\n<\/tr>\n<tr>\n<td>2.1.1 \u2014 Keyboard (A)<\/td>\n<td>\u2705 Pass<\/td>\n<td>Player is keyboard operable<\/td>\n<\/tr>\n<tr>\n<td>2.2.2 \u2014 Pause, Stop, Hide (A)<\/td>\n<td>\u2705 Pass<\/td>\n<td>Video does not autoplay; pause control available<\/td>\n<\/tr>\n<tr>\n<td>1.4.3 \u2014 Contrast (AA)<\/td>\n<td>\u2705 Pass<\/td>\n<td>Player uses a high-contrast color scheme<\/td>\n<\/tr>\n<tr>\n<td>4.1.2 \u2014 Name, Role, Value (A)<\/td>\n<td>\u2705 Pass<\/td>\n<td>Player labelled: \u201cCloudinary video player\u201d<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<p><strong>Score: 100 \/ 100<\/strong><\/p>\n<p>The score isn\u2019t 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.<\/p>\n<p><a href=\"https:\/\/cloudinary.com\/users\/register_free\">Sign up for a free Cloudinary account<\/a> to get started.<\/p>\n<p><strong>Resources<\/strong>:<\/p>\n<ul>\n<li>\n<p><strong>Live demo:<\/strong> <a href=\"https:\/\/video-accessibility.vercel.app\/\">https:\/\/video-accessibility.vercel.app\/<\/a><\/p>\n<\/li>\n<li>\n<p><strong>GitHub repository:<\/strong> <a href=\"https:\/\/github.com\/musebe\/video-accessibility\">https:\/\/github.com\/musebe\/video-accessibility<\/a><\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/cloudinary.com\/documentation\/video_transcription\">Video Transcription and Summarization<\/a><\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/cloudinary.com\/documentation\/image_upload_api_reference#explicit_method\">Upload API \u2014 explicit() method<\/a><\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/cloudinary.com\/documentation\/admin_api#get_resources\">Admin API \u2014 Get resource<\/a><\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/cloudinary.com\/documentation\/cloudinary_video_player\">Cloudinary Video Player<\/a><\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/cloudinary.com\/documentation\/cloudinary_video_player#text_tracks\">Text Tracks (captions, chapters)<\/a><\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/www.w3.org\/WAI\/WCAG21\/Understanding\/captions-prerecorded\">WCAG 2.1 Understanding \u2014 1.2.2 Captions<\/a><\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/www.w3.org\/WAI\/WCAG21\/Understanding\/audio-description-or-media-alternative-prerecorded\">WCAG 2.1 Understanding \u2014 1.2.3 Audio Description or Media Alternative<\/a><\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/www.w3.org\/WAI\/WCAG21\/Understanding\/audio-description-prerecorded\">WCAG 2.1 Understanding \u2014 1.2.5 Audio Description<\/a><\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/www.w3.org\/WAI\/WCAG21\/Understanding\/keyboard\">WCAG 2.1 Understanding \u2014 2.1.1 Keyboard<\/a><\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/www.w3.org\/WAI\/WCAG21\/Understanding\/pause-stop-hide\">WCAG 2.1 Understanding \u2014 2.2.2 Pause Stop Hide<\/a><\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/www.w3.org\/WAI\/WCAG21\/Understanding\/contrast-minimum\">WCAG 2.1 Understanding \u2014 1.4.3 Contrast Minimum<\/a><\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/www.w3.org\/WAI\/WCAG21\/Understanding\/name-role-value\">WCAG 2.1 Understanding \u2014 4.1.2 Name Role Value<\/a><\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/webaim.org\/techniques\/captions\/\">WebAIM \u2014 Captions, Transcripts, and Audio Descriptions<\/a><\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/WebVTT_API\">MDN \u2014 WebVTT format<\/a><\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/www.w3.org\/WAI\/media\/av\/\">W3C \u2014 Making Audio and Video Media Accessible<\/a><\/p>\n<\/li>\n<\/ul>\n<\/div>\n\n\n<style>\n.faqs {\n    padding: 30px 60px;\n    margin-top: 40px;\n    background: var(--color-background-offset);\n    border-radius: 20px;\n}\n\n#frequently_asked_questions {\n    margin-bottom: 20px;\n}\n\n.question {\n    margin-bottom: 12px;\n    border-bottom: 1px solid rgba(0, 0, 0, 0.12);\n}\n\n.question summary {\n    position: relative;\n    padding: 18px 40px 18px 0;\n    cursor: pointer;\n    font-weight: 700;\n    list-style: none;\n}\n\n.question summary::-webkit-details-marker {\n    display: none;\n}\n\n.question summary::after {\n    content: \"+\";\n    position: absolute;\n    top: 50%;\n    right: 0;\n    transform: translateY(-50%);\n    font-size: 24px;\n    font-weight: 400;\n    line-height: 1;\n}\n\n.question[open] summary::after {\n    content: \"\u2212\";\n}\n\n.answer {\n    padding: 0 40px 20px 0;\n}\n<\/style>\n\n<div class=\"faqs\">\n\n<h2 id=\"frequently_asked_questions\">Frequently Asked Questions<\/h2>\n\n<details class=\"question\">\n    <summary>Why is video accessibility frequently overlooked, and what are the legal standards teams must target?<\/summary>\n    <div class=\"answer\">Video accessibility is complex because it requires coordinating multiple synchronized streams of content \u2014 captions, audio descriptions, keyboard focus maps, and contrast criteria \u2014 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.<\/div>\n<\/details>\n\n<details class=\"question\">\n    <summary>How does Cloudinary automate the hardest WCAG Level A criteria, specifically Captions (1.2.2) and Media Alternatives (1.2.3)?<\/summary>\n    <div class=\"answer\">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 \u2014 satisfying both synchronized captions and media alternatives programmatically.<\/div>\n<\/details>\n\n<details class=\"question\">\n    <summary>How can developers satisfy the WCAG 1.2.5 Audio Description (Level AA) requirement without producing an expensive second narration track?<\/summary>\n    <div class=\"answer\">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&#8217;s AI-generated transcript serves as programmatic proof of spoken commentary \u2014 if the transcript is ready and populated with content, the system confirms the video is narrated, and the audit passes automatically.<\/div>\n<\/details>\n\n<details class=\"question\">\n    <summary>How does the application ensure full Keyboard Accessibility (2.1.1) and Pause Controls (2.2.2) at Level A?<\/summary>\n    <div class=\"answer\">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.<\/div>\n<\/details>\n\n<details class=\"question\">\n    <summary>How is the minimum 4.5:1 Contrast Ratio (1.4.3) maintained for player UI elements and captions?<\/summary>\n    <div class=\"answer\">The player configuration uses a high-contrast color scheme \u2014 a dark navy control bar, an indigo scrubber, and near-white text labels \u2014 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.<\/div>\n<\/details>\n\n<details class=\"question\">\n    <summary>Why are AI-generated chapters important for accessibility, and how does the player discover them?<\/summary>\n    <div class=\"answer\">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&#8217;s text tracks on startup, and once the chapter cues are found, they populate the player&#8217;s keyboard-navigable chapters menu.<\/div>\n<\/details>\n\n<details class=\"question\">\n    <summary>What is the difference between lazy and eager transcoding for accessibility assets in Cloudinary?<\/summary>\n    <div class=\"answer\">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 \u2014 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.<\/div>\n<\/details>\n\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>Frequently Asked Questions 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 \u2014 captions, audio descriptions, keyboard focus maps, and contrast criteria \u2014 and most teams stop at simple image alt text. Legally, the Americans with [&hellip;]<\/p>\n","protected":false},"author":87,"featured_media":40598,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"_cloudinary_featured_overwrite":false,"footnotes":"","jetpack_post_was_ever_published":false},"categories":[1],"tags":[337,336,403,303,310],"class_list":["post-40535","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-uncategorized","tag-accessibility","tag-ai","tag-transcriptions","tag-video","tag-video-player"],"acf":[],"yoast_head":"<!-- This site is optimized with the Yoast SEO Premium plugin v28.3 (Yoast SEO v28.3) - https:\/\/yoast.com\/product\/yoast-seo-premium-wordpress\/ -->\n<title>100\/100 WCAG 2.1 AA Video Accessibility: Cloudinary AI &amp; Next.js<\/title>\n<meta name=\"description\" content=\"Build a fully compliant video experience in Next.js with Cloudinary&#039;s AI pipeline. Learn about auto-captions, interactive synced transcript panels, high-contrast styling, and keyboard navigation.\" \/>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/cloudinary.com\/blog\/wcag-video-accessibility-cloudinary-nextjs\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"Achieving 100\/100 WCAG 2.1 AA Video Accessibility With Cloudinary AI in Next.js\" \/>\n<meta property=\"og:description\" content=\"Build a fully compliant video experience in Next.js with Cloudinary&#039;s AI pipeline. Learn about auto-captions, interactive synced transcript panels, high-contrast styling, and keyboard navigation.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/cloudinary.com\/blog\/wcag-video-accessibility-cloudinary-nextjs\" \/>\n<meta property=\"og:site_name\" content=\"Cloudinary Blog\" \/>\n<meta property=\"article:published_time\" content=\"2026-10-20T14:00:00+00:00\" \/>\n<meta property=\"article:modified_time\" content=\"2026-10-23T17:28:08+00:00\" \/>\n<meta name=\"author\" content=\"melindapham\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:image\" content=\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1792776483\/Web_Assets\/blog\/100_WCAG_2.1_AA_Video_Accessibility_with_Cloudinary_AI_in_Next.js_\/100_WCAG_2.1_AA_Video_Accessibility_with_Cloudinary_AI_in_Next.js_.png?_i=AA\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/wcag-video-accessibility-cloudinary-nextjs#article\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/wcag-video-accessibility-cloudinary-nextjs\"},\"author\":{\"name\":\"melindapham\",\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/#\\\/schema\\\/person\\\/0d5ad601e4c3b5be89245dfb14be42d9\"},\"headline\":\"Achieving 100\\\/100 WCAG 2.1 AA Video Accessibility With Cloudinary AI in Next.js\",\"datePublished\":\"2026-10-20T14:00:00+00:00\",\"dateModified\":\"2026-10-23T17:28:08+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/wcag-video-accessibility-cloudinary-nextjs\"},\"wordCount\":729,\"publisher\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/#organization\"},\"image\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/wcag-video-accessibility-cloudinary-nextjs#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/res.cloudinary.com\\\/cloudinary-marketing\\\/images\\\/f_auto,q_auto\\\/v1792776483\\\/Web_Assets\\\/blog\\\/100_WCAG_2.1_AA_Video_Accessibility_with_Cloudinary_AI_in_Next.js_\\\/100_WCAG_2.1_AA_Video_Accessibility_with_Cloudinary_AI_in_Next.js_.png?_i=AA\",\"keywords\":[\"Accessibility\",\"AI\",\"Transcriptions\",\"Video\",\"Video Player\"],\"inLanguage\":\"en-US\",\"copyrightYear\":\"2026\",\"copyrightHolder\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/#organization\"}},{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/wcag-video-accessibility-cloudinary-nextjs\",\"url\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/wcag-video-accessibility-cloudinary-nextjs\",\"name\":\"100\\\/100 WCAG 2.1 AA Video Accessibility: Cloudinary AI & Next.js\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/wcag-video-accessibility-cloudinary-nextjs#primaryimage\"},\"image\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/wcag-video-accessibility-cloudinary-nextjs#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/res.cloudinary.com\\\/cloudinary-marketing\\\/images\\\/f_auto,q_auto\\\/v1792776483\\\/Web_Assets\\\/blog\\\/100_WCAG_2.1_AA_Video_Accessibility_with_Cloudinary_AI_in_Next.js_\\\/100_WCAG_2.1_AA_Video_Accessibility_with_Cloudinary_AI_in_Next.js_.png?_i=AA\",\"datePublished\":\"2026-10-20T14:00:00+00:00\",\"dateModified\":\"2026-10-23T17:28:08+00:00\",\"description\":\"Build a fully compliant video experience in Next.js with Cloudinary's AI pipeline. Learn about auto-captions, interactive synced transcript panels, high-contrast styling, and keyboard navigation.\",\"breadcrumb\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/wcag-video-accessibility-cloudinary-nextjs#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/cloudinary.com\\\/blog\\\/wcag-video-accessibility-cloudinary-nextjs\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/wcag-video-accessibility-cloudinary-nextjs#primaryimage\",\"url\":\"https:\\\/\\\/res.cloudinary.com\\\/cloudinary-marketing\\\/images\\\/f_auto,q_auto\\\/v1792776483\\\/Web_Assets\\\/blog\\\/100_WCAG_2.1_AA_Video_Accessibility_with_Cloudinary_AI_in_Next.js_\\\/100_WCAG_2.1_AA_Video_Accessibility_with_Cloudinary_AI_in_Next.js_.png?_i=AA\",\"contentUrl\":\"https:\\\/\\\/res.cloudinary.com\\\/cloudinary-marketing\\\/images\\\/f_auto,q_auto\\\/v1792776483\\\/Web_Assets\\\/blog\\\/100_WCAG_2.1_AA_Video_Accessibility_with_Cloudinary_AI_in_Next.js_\\\/100_WCAG_2.1_AA_Video_Accessibility_with_Cloudinary_AI_in_Next.js_.png?_i=AA\",\"width\":2000,\"height\":1100},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/wcag-video-accessibility-cloudinary-nextjs#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Achieving 100\\\/100 WCAG 2.1 AA Video Accessibility With Cloudinary AI in Next.js\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/#website\",\"url\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/\",\"name\":\"Cloudinary Blog\",\"description\":\"\",\"publisher\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/#organization\"},\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":\"Organization\",\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/#organization\",\"name\":\"Cloudinary Blog\",\"url\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/\",\"logo\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/#\\\/schema\\\/logo\\\/image\\\/\",\"url\":\"https:\\\/\\\/res.cloudinary.com\\\/cloudinary-marketing\\\/images\\\/f_auto,q_auto\\\/v1649718331\\\/Web_Assets\\\/blog\\\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\\\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA\",\"contentUrl\":\"https:\\\/\\\/res.cloudinary.com\\\/cloudinary-marketing\\\/images\\\/f_auto,q_auto\\\/v1649718331\\\/Web_Assets\\\/blog\\\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\\\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA\",\"width\":312,\"height\":60,\"caption\":\"Cloudinary Blog\"},\"image\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/#\\\/schema\\\/logo\\\/image\\\/\"}},{\"@type\":\"Person\",\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/#\\\/schema\\\/person\\\/0d5ad601e4c3b5be89245dfb14be42d9\",\"name\":\"melindapham\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g\",\"url\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g\",\"contentUrl\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g\",\"caption\":\"melindapham\"}}]}<\/script>\n<!-- \/ Yoast SEO Premium plugin. -->","yoast_head_json":{"title":"100\/100 WCAG 2.1 AA Video Accessibility: Cloudinary AI & Next.js","description":"Build a fully compliant video experience in Next.js with Cloudinary's AI pipeline. Learn about auto-captions, interactive synced transcript panels, high-contrast styling, and keyboard navigation.","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/cloudinary.com\/blog\/wcag-video-accessibility-cloudinary-nextjs","og_locale":"en_US","og_type":"article","og_title":"Achieving 100\/100 WCAG 2.1 AA Video Accessibility With Cloudinary AI in Next.js","og_description":"Build a fully compliant video experience in Next.js with Cloudinary's AI pipeline. Learn about auto-captions, interactive synced transcript panels, high-contrast styling, and keyboard navigation.","og_url":"https:\/\/cloudinary.com\/blog\/wcag-video-accessibility-cloudinary-nextjs","og_site_name":"Cloudinary Blog","article_published_time":"2026-10-20T14:00:00+00:00","article_modified_time":"2026-10-23T17:28:08+00:00","author":"melindapham","twitter_card":"summary_large_image","twitter_image":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1792776483\/Web_Assets\/blog\/100_WCAG_2.1_AA_Video_Accessibility_with_Cloudinary_AI_in_Next.js_\/100_WCAG_2.1_AA_Video_Accessibility_with_Cloudinary_AI_in_Next.js_.png?_i=AA","schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/cloudinary.com\/blog\/wcag-video-accessibility-cloudinary-nextjs#article","isPartOf":{"@id":"https:\/\/cloudinary.com\/blog\/wcag-video-accessibility-cloudinary-nextjs"},"author":{"name":"melindapham","@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/0d5ad601e4c3b5be89245dfb14be42d9"},"headline":"Achieving 100\/100 WCAG 2.1 AA Video Accessibility With Cloudinary AI in Next.js","datePublished":"2026-10-20T14:00:00+00:00","dateModified":"2026-10-23T17:28:08+00:00","mainEntityOfPage":{"@id":"https:\/\/cloudinary.com\/blog\/wcag-video-accessibility-cloudinary-nextjs"},"wordCount":729,"publisher":{"@id":"https:\/\/cloudinary.com\/blog\/#organization"},"image":{"@id":"https:\/\/cloudinary.com\/blog\/wcag-video-accessibility-cloudinary-nextjs#primaryimage"},"thumbnailUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1792776483\/Web_Assets\/blog\/100_WCAG_2.1_AA_Video_Accessibility_with_Cloudinary_AI_in_Next.js_\/100_WCAG_2.1_AA_Video_Accessibility_with_Cloudinary_AI_in_Next.js_.png?_i=AA","keywords":["Accessibility","AI","Transcriptions","Video","Video Player"],"inLanguage":"en-US","copyrightYear":"2026","copyrightHolder":{"@id":"https:\/\/cloudinary.com\/#organization"}},{"@type":"WebPage","@id":"https:\/\/cloudinary.com\/blog\/wcag-video-accessibility-cloudinary-nextjs","url":"https:\/\/cloudinary.com\/blog\/wcag-video-accessibility-cloudinary-nextjs","name":"100\/100 WCAG 2.1 AA Video Accessibility: Cloudinary AI & Next.js","isPartOf":{"@id":"https:\/\/cloudinary.com\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/cloudinary.com\/blog\/wcag-video-accessibility-cloudinary-nextjs#primaryimage"},"image":{"@id":"https:\/\/cloudinary.com\/blog\/wcag-video-accessibility-cloudinary-nextjs#primaryimage"},"thumbnailUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1792776483\/Web_Assets\/blog\/100_WCAG_2.1_AA_Video_Accessibility_with_Cloudinary_AI_in_Next.js_\/100_WCAG_2.1_AA_Video_Accessibility_with_Cloudinary_AI_in_Next.js_.png?_i=AA","datePublished":"2026-10-20T14:00:00+00:00","dateModified":"2026-10-23T17:28:08+00:00","description":"Build a fully compliant video experience in Next.js with Cloudinary's AI pipeline. Learn about auto-captions, interactive synced transcript panels, high-contrast styling, and keyboard navigation.","breadcrumb":{"@id":"https:\/\/cloudinary.com\/blog\/wcag-video-accessibility-cloudinary-nextjs#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/cloudinary.com\/blog\/wcag-video-accessibility-cloudinary-nextjs"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/cloudinary.com\/blog\/wcag-video-accessibility-cloudinary-nextjs#primaryimage","url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1792776483\/Web_Assets\/blog\/100_WCAG_2.1_AA_Video_Accessibility_with_Cloudinary_AI_in_Next.js_\/100_WCAG_2.1_AA_Video_Accessibility_with_Cloudinary_AI_in_Next.js_.png?_i=AA","contentUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1792776483\/Web_Assets\/blog\/100_WCAG_2.1_AA_Video_Accessibility_with_Cloudinary_AI_in_Next.js_\/100_WCAG_2.1_AA_Video_Accessibility_with_Cloudinary_AI_in_Next.js_.png?_i=AA","width":2000,"height":1100},{"@type":"BreadcrumbList","@id":"https:\/\/cloudinary.com\/blog\/wcag-video-accessibility-cloudinary-nextjs#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/cloudinary.com\/blog\/"},{"@type":"ListItem","position":2,"name":"Achieving 100\/100 WCAG 2.1 AA Video Accessibility With Cloudinary AI in Next.js"}]},{"@type":"WebSite","@id":"https:\/\/cloudinary.com\/blog\/#website","url":"https:\/\/cloudinary.com\/blog\/","name":"Cloudinary Blog","description":"","publisher":{"@id":"https:\/\/cloudinary.com\/blog\/#organization"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/cloudinary.com\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":"Organization","@id":"https:\/\/cloudinary.com\/blog\/#organization","name":"Cloudinary Blog","url":"https:\/\/cloudinary.com\/blog\/","logo":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/logo\/image\/","url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1649718331\/Web_Assets\/blog\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA","contentUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1649718331\/Web_Assets\/blog\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA","width":312,"height":60,"caption":"Cloudinary Blog"},"image":{"@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/logo\/image\/"}},{"@type":"Person","@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/0d5ad601e4c3b5be89245dfb14be42d9","name":"melindapham","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/secure.gravatar.com\/avatar\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g","url":"https:\/\/secure.gravatar.com\/avatar\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g","contentUrl":"https:\/\/secure.gravatar.com\/avatar\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g","caption":"melindapham"}}]}},"parsely":{"version":"1.1.0","canonical_url":"https:\/\/cloudinary.com\/blog\/wcag-video-accessibility-cloudinary-nextjs","meta":{"@context":"https:\/\/schema.org","@type":"NewsArticle","headline":"Achieving 100\/100 WCAG 2.1 AA Video Accessibility With Cloudinary AI in Next.js","url":"https:\/\/cloudinary.com\/blog\/wcag-video-accessibility-cloudinary-nextjs","mainEntityOfPage":{"@type":"WebPage","@id":"https:\/\/cloudinary.com\/blog\/wcag-video-accessibility-cloudinary-nextjs"},"thumbnailUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1792776483\/Web_Assets\/blog\/100_WCAG_2.1_AA_Video_Accessibility_with_Cloudinary_AI_in_Next.js_\/100_WCAG_2.1_AA_Video_Accessibility_with_Cloudinary_AI_in_Next.js_.png?_i=AA&w=150&h=150&crop=1","image":{"@type":"ImageObject","url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1792776483\/Web_Assets\/blog\/100_WCAG_2.1_AA_Video_Accessibility_with_Cloudinary_AI_in_Next.js_\/100_WCAG_2.1_AA_Video_Accessibility_with_Cloudinary_AI_in_Next.js_.png?_i=AA"},"articleSection":"Uncategorized","author":[{"@type":"Person","name":"melindapham"}],"creator":["melindapham"],"publisher":{"@type":"Organization","name":"Cloudinary Blog","logo":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/v1649718331\/Web_Assets\/blog\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA"},"keywords":["accessibility","ai","transcriptions","video","video player"],"dateCreated":"2026-10-20T14:00:00Z","datePublished":"2026-10-20T14:00:00Z","dateModified":"2026-10-23T17:28:08Z"},"rendered":"<meta name=\"parsely-title\" content=\"Achieving 100\/100 WCAG 2.1 AA Video Accessibility With Cloudinary AI in Next.js\" \/>\n<meta name=\"parsely-link\" content=\"https:\/\/cloudinary.com\/blog\/wcag-video-accessibility-cloudinary-nextjs\" \/>\n<meta name=\"parsely-type\" content=\"post\" \/>\n<meta name=\"parsely-image-url\" content=\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1792776483\/Web_Assets\/blog\/100_WCAG_2.1_AA_Video_Accessibility_with_Cloudinary_AI_in_Next.js_\/100_WCAG_2.1_AA_Video_Accessibility_with_Cloudinary_AI_in_Next.js_.png?_i=AA&w=150&amp;h=150&amp;crop=1\" \/>\n<meta name=\"parsely-pub-date\" content=\"2026-10-20T14:00:00Z\" \/>\n<meta name=\"parsely-section\" content=\"Uncategorized\" \/>\n<meta name=\"parsely-tags\" content=\"accessibility,ai,transcriptions,video,video player\" \/>\n<meta name=\"parsely-author\" content=\"melindapham\" \/>","tracker_url":"https:\/\/cdn.parsely.com\/keys\/cloudinary.com\/p.js"},"jetpack_featured_media_url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1792776483\/Web_Assets\/blog\/100_WCAG_2.1_AA_Video_Accessibility_with_Cloudinary_AI_in_Next.js_\/100_WCAG_2.1_AA_Video_Accessibility_with_Cloudinary_AI_in_Next.js_.png?_i=AA","_links":{"self":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts\/40535","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/users\/87"}],"replies":[{"embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/comments?post=40535"}],"version-history":[{"count":2,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts\/40535\/revisions"}],"predecessor-version":[{"id":40538,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts\/40535\/revisions\/40538"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/media\/40598"}],"wp:attachment":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/media?parent=40535"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/categories?post=40535"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/tags?post=40535"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}