Make videos accessible with captions
rule · video-accessibility
Accessible videos ensure all users can understand and interact with video content.
Code Example
<figure>
<video
controls
preload="metadata"
poster="thumbnail.jpg"
aria-describedby="video-description"
>
<source src="video.mp4" type="video/mp4" />
<source src="video.webm" type="video/webm" />
<!-- Captions (for deaf/hard of hearing) -->
<track
kind="captions"
src="captions-en.vtt"
srclang="en"
label="English captions"
default
/>
<track
kind="captions"
src="captions-es.vtt"
srclang="es"
label="Spanish captions"
/>
<!-- Audio descriptions (for blind users) -->
<track
kind="descriptions"
src="descriptions-en.vtt"
srclang="en"
label="English audio descriptions"
/>
<!-- Fallback -->
<p>
Your browser doesn't support HTML5 video.
<a href="video.mp4">Download the video</a>.
</p>
</video>
<figcaption id="video-description">
Product demonstration showing how to use the dashboard features.
</figcaption>
</figure>Why It Matters
Videos without captions exclude deaf and hard-of-hearing users, while videos without audio descriptions exclude blind users from understanding visual content.
Accessibility Requirements
| Requirement | Implementation |
|---|---|
| Captions | <track kind="captions"> for deaf users |
| Audio descriptions | <track kind="descriptions"> for blind users |
| Transcript | Text alternative for video content |
| Keyboard controls | Accessible play/pause/seek |
| No autoplay | Prevent unwanted playback |
Auto-generated captions are a starting point, not a finished accessibility deliverable. Review them for speaker names, punctuation, technical terms, and meaningful sound effects.
Caption File Format (WebVTT)
WEBVTT
00:00:00.000 --> 00:00:04.000
Welcome to our product demonstration.
00:00:04.500 --> 00:00:08.000
Today we'll show you the key features
of the dashboard.
00:00:08.500 --> 00:00:12.000
[Background music playing]
00:00:12.500 --> 00:00:16.000
First, let's look at the navigation menu.Include non-speech audio such as [applause], [laughter], or [door closes] when it changes understanding.
React Video Component
import { useRef, useState, useEffect } from 'react'
interface VideoTrack {
kind: 'captions' | 'descriptions' | 'subtitles'
src: string
srclang: string
label: string
default?: boolean
}
interface AccessibleVideoProps {
src: string
poster?: string
title: string
description?: string
tracks?: VideoTrack[]
transcript?: string
}
export function AccessibleVideo({
src,
poster,
title,
description,
tracks = [],
transcript
}: AccessibleVideoProps) {
const videoRef = useRef<HTMLVideoElement>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [showTranscript, setShowTranscript] = useState(false)
// Prevent spacebar from scrolling page
useEffect(() => {
const video = videoRef.current
if (!video) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === ' ' && document.activeElement === video) {
e.preventDefault()
if (video.paused) {
video.play()
} else {
video.pause()
}
}
}
video.addEventListener('keydown', handleKeyDown)
return () => video.removeEventListener('keydown', handleKeyDown)
}, [])
const togglePlay = () => {
const video = videoRef.current
if (!video) return
if (video.paused) {
video.play()
setIsPlaying(true)
} else {
video.pause()
setIsPlaying(false)
}
}
return (
<div className="video-container">
<figure>
<video
ref={videoRef}
controls
preload="metadata"
poster={poster}
aria-label={title}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
>
<source src={src} type="video/mp4" />
{tracks.map((track, index) => (
<track
key={index}
kind={track.kind}
src={track.src}
srcLang={track.srclang}
label={track.label}
default={track.default}
/>
))}
</video>
{description && (
<figcaption className="video-description">
{description}
</figcaption>
)}
</figure>
{/* Custom accessible controls */}
<div className="video-controls" role="group" aria-label="Video controls">
<button
type="button"
onClick={togglePlay}
aria-label={isPlaying ? 'Pause video' : 'Play video'}
>
{isPlaying ? '⏸ Pause' : '▶ Play'}
</button>
</div>
{/* Transcript toggle */}
{transcript && (
<div className="video-transcript">
<button
type="button"
onClick={() => setShowTranscript(!showTranscript)}
aria-expanded={showTranscript}
aria-controls="transcript"
>
{showTranscript ? 'Hide' : 'Show'} Transcript
</button>
{showTranscript && (
<div id="transcript" className="transcript-content">
{transcript}
</div>
)}
</div>
)}
</div>
)
}Usage
<AccessibleVideo
src="/videos/product-demo.mp4"
poster="/images/video-thumbnail.jpg"
title="Product Dashboard Demo"
description="A walkthrough of the main dashboard features"
tracks={[
{
kind: 'captions',
src: '/videos/captions-en.vtt',
srclang: 'en',
label: 'English',
default: true
},
{
kind: 'descriptions',
src: '/videos/descriptions-en.vtt',
srclang: 'en',
label: 'Audio descriptions'
}
]}
transcript={`
Welcome to our product demonstration. Today we'll show you
the key features of the dashboard. First, let's look at
the navigation menu...
`}
/>Video with Reduced Motion Support
function ResponsiveVideo({ src, poster }: { src: string; poster: string }) {
const [shouldAutoplay, setShouldAutoplay] = useState(false)
useEffect(() => {
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches
setShouldAutoplay(!prefersReducedMotion)
}, [])
return (
<video
controls
poster={poster}
autoPlay={shouldAutoplay}
muted={shouldAutoplay} // Autoplay requires muted
loop={shouldAutoplay}
>
<source src={src} type="video/mp4" />
</video>
)
}Embedded Videos (YouTube/Vimeo)
Third-party players do not remove your accessibility responsibility.
- Verify captions are available and accurate, not only auto-generated.
- Provide a transcript link when the player makes transcript discovery difficult.
- Confirm keyboard users can reach pause, captions, and volume controls.
- Avoid autoplay with sound on embeds even when the provider allows it.
interface EmbeddedVideoProps {
videoId: string
title: string
provider: 'youtube' | 'vimeo'
}
export function EmbeddedVideo({ videoId, title, provider }: EmbeddedVideoProps) {
const src = provider === 'youtube'
? `https://www.youtube-nocookie.com/embed/${videoId}?cc_load_policy=1`
: `https://player.vimeo.com/video/${videoId}?texttrack=en`
return (
<div className="video-embed">
<iframe
src={src}
title={title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
loading="lazy"
/>
</div>
)
}Caption Best Practices
| Do | Don't |
|---|---|
| Include speaker identification | Rely on auto-generated captions |
| Describe relevant sounds [door slams] | Censor or summarize dialogue |
| Sync captions with speech | Use all caps for entire captions |
| Include music descriptions [upbeat music] | Overlap captions with visuals |
If the narration does not explain important on-screen actions, pair captions with audio descriptions or a detailed transcript. | Keep lines short (32-40 characters) | Leave sounds undescribed |
Styling
.video-container {
max-width: 800px;
margin: 0 auto;
}
.video-container video {
width: 100%;
display: block;
}
.video-description {
font-size: 0.875rem;
color: #666;
margin-top: 0.5rem;
}
.video-controls {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.video-controls button {
padding: 0.5rem 1rem;
background: #333;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.video-controls button:focus-visible {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
.video-transcript button {
margin-top: 1rem;
padding: 0.5rem 1rem;
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
}
.transcript-content {
margin-top: 1rem;
padding: 1rem;
background: #f9f9f9;
border-radius: 4px;
max-height: 300px;
overflow-y: auto;
}
/* Responsive embed */
.video-embed {
position: relative;
padding-bottom: 56.25%; /* 16:9 */
height: 0;
overflow: hidden;
}
.video-embed iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
}Verification
- Verify captions display correctly and are synchronized
- Test audio descriptions are available and accurate
- Check transcript is complete and accessible
- Verify keyboard controls work (space to pause)
- Test with screen reader (video title announced)
- Confirm no autoplay with sound
- Check prefers-reduced-motion is respected