UI Components Medium
Accessible Media Captions
Video player with synchronized captions and subtitles, caption customization controls and keyboard-accessible media controls.
Open in Lab
MCP
vanilla-js html5
Targets: JS HTML
Code
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #0a0a0a;
color: #e4e4e7;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
width: 100%;
max-width: 860px;
padding: 2rem 1.5rem;
}
.page-title {
font-size: 1.5rem;
font-weight: 700;
color: #f4f4f5;
margin-bottom: 0.5rem;
letter-spacing: -0.02em;
}
.page-desc {
font-size: 0.8125rem;
color: #71717a;
margin-bottom: 1.5rem;
line-height: 1.6;
}
kbd {
display: inline-block;
padding: 0.125rem 0.375rem;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
font-family: inherit;
font-size: 0.75rem;
color: #a1a1aa;
}
/* ---- Player ---- */
.player {
position: relative;
border-radius: 12px;
overflow: hidden;
background: #000;
border: 1px solid rgba(255, 255, 255, 0.08);
outline: none;
}
.player:focus-visible {
box-shadow: 0 0 0 2px #8b5cf6;
}
.player__viewport {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
cursor: pointer;
}
.player__canvas {
display: block;
width: 100%;
height: 100%;
}
/* Play overlay */
.player__play-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
border: none;
color: #fff;
cursor: pointer;
transition: opacity 0.3s ease;
}
.player__play-overlay.hidden {
opacity: 0;
pointer-events: none;
}
/* Caption overlay */
.player__captions {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 60px;
max-width: 80%;
text-align: center;
pointer-events: none;
z-index: 5;
transition: top 0.3s ease, bottom 0.3s ease;
}
.player__captions[data-pos="top"] {
bottom: auto;
top: 20px;
}
.caption-text {
display: inline-block;
padding: 0.375rem 0.75rem;
background: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 18px;
line-height: 1.5;
border-radius: 4px;
font-weight: 500;
}
.caption-text:empty {
display: none;
}
/* ---- Controls ---- */
.player__controls {
background: rgba(0, 0, 0, 0.85);
padding: 0 0.75rem 0.5rem;
}
/* Progress bar */
.progress-bar {
width: 100%;
height: 20px;
display: flex;
align-items: center;
cursor: pointer;
padding: 8px 0;
}
.progress-bar::before {
content: "";
display: none;
}
.progress-bar {
position: relative;
background: transparent;
}
.progress-bar::after {
content: "";
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 4px;
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
transform: translateY(-50%);
}
.progress-bar__fill {
position: absolute;
top: 50%;
left: 0;
height: 4px;
background: #8b5cf6;
border-radius: 2px;
transform: translateY(-50%);
z-index: 1;
width: 0%;
transition: width 0.1s linear;
}
.progress-bar:hover .progress-bar__fill {
height: 6px;
}
.controls-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.controls-left,
.controls-right {
display: flex;
align-items: center;
gap: 0.375rem;
}
.ctrl-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: 6px;
background: transparent;
color: #d4d4d8;
cursor: pointer;
transition: all 0.15s ease;
}
.ctrl-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.ctrl-btn:focus-visible {
outline: 2px solid #8b5cf6;
outline-offset: 2px;
}
.ctrl-btn[aria-pressed="true"] {
color: #8b5cf6;
}
/* Volume */
.volume-slider {
width: 70px;
}
.volume-range {
width: 100%;
appearance: none;
height: 4px;
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
outline: none;
}
.volume-range::-webkit-slider-thumb {
appearance: none;
width: 12px;
height: 12px;
background: #fff;
border-radius: 50%;
cursor: pointer;
}
.time-display {
font-size: 0.75rem;
color: #a1a1aa;
font-variant-numeric: tabular-nums;
margin-left: 0.25rem;
white-space: nowrap;
}
/* ---- Settings panel ---- */
.settings-panel {
position: absolute;
right: 0.75rem;
bottom: 70px;
width: 260px;
background: rgba(24, 24, 27, 0.97);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 1rem;
z-index: 20;
backdrop-filter: blur(12px);
}
.settings-panel__title {
font-size: 0.8125rem;
font-weight: 600;
color: #f4f4f5;
margin-bottom: 1rem;
}
.setting {
margin-bottom: 0.875rem;
}
.setting:last-child {
margin-bottom: 0;
}
.setting__label {
display: block;
font-size: 0.75rem;
color: #71717a;
margin-bottom: 0.375rem;
}
.setting__row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.setting__range {
flex: 1;
appearance: none;
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
outline: none;
}
.setting__range::-webkit-slider-thumb {
appearance: none;
width: 14px;
height: 14px;
background: #8b5cf6;
border-radius: 50%;
cursor: pointer;
}
.setting__value {
font-size: 0.75rem;
color: #a1a1aa;
min-width: 32px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.setting__colors {
display: flex;
gap: 0.5rem;
}
.cap-color {
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
transition: all 0.2s ease;
}
.cap-color:hover {
transform: scale(1.15);
}
.cap-color.active {
border-color: #8b5cf6;
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.4);
}
.setting__positions {
display: flex;
gap: 0.5rem;
}
.pos-btn {
flex: 1;
padding: 0.375rem 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
background: transparent;
color: #71717a;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
}
.pos-btn:hover {
background: rgba(255, 255, 255, 0.05);
}
.pos-btn.active {
background: rgba(139, 92, 246, 0.15);
border-color: rgba(139, 92, 246, 0.3);
color: #c4b5fd;
}(() => {
// Caption data (simulated timed captions)
const captions = [
{ start: 0, end: 4, text: "Welcome to the accessible media player demonstration." },
{ start: 4, end: 8, text: "This player features full keyboard navigation." },
{ start: 8, end: 13, text: "Press Space or K to play and pause." },
{ start: 13, end: 17, text: "Use the left and right arrow keys to seek." },
{ start: 17, end: 22, text: "Press M to toggle mute, and F for fullscreen." },
{ start: 22, end: 27, text: "Captions can be toggled with the C key." },
{ start: 27, end: 32, text: "You can customize caption font size and color." },
{ start: 32, end: 37, text: "Background opacity helps readability on any content." },
{ start: 37, end: 42, text: "Captions can be positioned at the top or bottom." },
{ start: 42, end: 48, text: "All controls are accessible via keyboard and screen readers." },
{ start: 48, end: 54, text: "Proper ARIA attributes announce state changes." },
{ start: 54, end: 60, text: "Volume is adjustable with the slider control." },
{ start: 60, end: 66, text: "The progress bar allows seeking to any position." },
{ start: 66, end: 72, text: "This ensures all users can consume media content." },
{ start: 72, end: 78, text: "Inclusive design makes media available to everyone." },
{ start: 78, end: 84, text: "Custom captions meet diverse accessibility needs." },
{ start: 84, end: 90, text: "Settings are saved for a consistent experience." },
{ start: 90, end: 96, text: "Try adjusting the caption settings panel." },
{ start: 96, end: 102, text: "Explore the keyboard shortcuts for efficient control." },
{ start: 102, end: 110, text: "Thank you for exploring accessible media captions." },
{ start: 110, end: 120, text: "Building inclusive media experiences matters." },
{ start: 120, end: 130, text: "Every user deserves equal access to media content." },
{ start: 130, end: 140, text: "Accessibility is not optional, it is essential." },
{ start: 140, end: 150, text: "This concludes the accessible media captions demo." },
];
const TOTAL_DURATION = 150; // seconds
// Elements
const player = document.getElementById("player");
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const playOverlay = document.getElementById("play-overlay");
const btnPlay = document.getElementById("btn-play");
const btnMute = document.getElementById("btn-mute");
const btnCaptions = document.getElementById("btn-captions");
const btnSettings = document.getElementById("btn-settings");
const progressBar = document.getElementById("progress-bar");
const progressFill = document.getElementById("progress-fill");
const timeDisplay = document.getElementById("time-display");
const captionsEl = document.getElementById("captions");
const captionText = document.getElementById("caption-text");
const settingsPanel = document.getElementById("settings-panel");
// Caption settings elements
const capFontSize = document.getElementById("cap-font-size");
const capFontSizeVal = document.getElementById("cap-font-size-val");
const capBgOpacity = document.getElementById("cap-bg-opacity");
const capBgOpacityVal = document.getElementById("cap-bg-opacity-val");
const capColors = document.querySelectorAll(".cap-color");
const posBtns = document.querySelectorAll(".pos-btn");
// State
let playing = false;
let muted = false;
let captionsEnabled = true;
let currentTime = 0;
let animFrame = null;
let lastTimestamp = null;
// Canvas animation (color-shifting gradient)
const colors = [
{ r: 30, g: 15, b: 60 },
{ r: 20, g: 40, b: 80 },
{ r: 50, g: 20, b: 70 },
{ r: 15, g: 50, b: 60 },
];
function lerpColor(a, b, t) {
return {
r: Math.round(a.r + (b.r - a.r) * t),
g: Math.round(a.g + (b.g - a.g) * t),
b: Math.round(a.b + (b.b - a.b) * t),
};
}
function drawCanvas() {
const w = canvas.width;
const h = canvas.height;
const t = (currentTime % 20) / 20;
const idx = Math.floor(t * colors.length) % colors.length;
const next = (idx + 1) % colors.length;
const frac = (t * colors.length) % 1;
const c1 = lerpColor(colors[idx], colors[next], frac);
const c2 = lerpColor(
colors[(idx + 2) % colors.length],
colors[(idx + 3) % colors.length],
frac
);
const grad = ctx.createLinearGradient(0, 0, w, h);
grad.addColorStop(0, `rgb(${c1.r},${c1.g},${c1.b})`);
grad.addColorStop(1, `rgb(${c2.r},${c2.g},${c2.b})`);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, w, h);
// Draw subtle grid
ctx.strokeStyle = "rgba(255,255,255,0.03)";
ctx.lineWidth = 1;
for (let x = 0; x < w; x += 40) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, h);
ctx.stroke();
}
for (let y = 0; y < h; y += 40) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
// Floating circle
const cx = w / 2 + Math.sin(currentTime * 0.5) * 120;
const cy = h / 2 + Math.cos(currentTime * 0.3) * 80;
const circGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, 80);
circGrad.addColorStop(0, "rgba(139,92,246,0.25)");
circGrad.addColorStop(1, "rgba(139,92,246,0)");
ctx.fillStyle = circGrad;
ctx.beginPath();
ctx.arc(cx, cy, 80, 0, Math.PI * 2);
ctx.fill();
}
function formatTime(s) {
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, "0")}`;
}
function updateCaption() {
if (!captionsEnabled) {
captionText.textContent = "";
return;
}
const active = captions.find((c) => currentTime >= c.start && currentTime < c.end);
captionText.textContent = active ? active.text : "";
}
function updateUI() {
const pct = (currentTime / TOTAL_DURATION) * 100;
progressFill.style.width = pct + "%";
progressBar.setAttribute("aria-valuenow", Math.round(pct));
timeDisplay.textContent = `${formatTime(currentTime)} / ${formatTime(TOTAL_DURATION)}`;
updateCaption();
}
function tick(timestamp) {
if (!playing) return;
if (lastTimestamp === null) lastTimestamp = timestamp;
const delta = (timestamp - lastTimestamp) / 1000;
lastTimestamp = timestamp;
currentTime += delta;
if (currentTime >= TOTAL_DURATION) {
currentTime = 0;
pause();
}
drawCanvas();
updateUI();
animFrame = requestAnimationFrame(tick);
}
function play() {
playing = true;
lastTimestamp = null;
playOverlay.classList.add("hidden");
btnPlay.querySelector(".icon-play").style.display = "none";
btnPlay.querySelector(".icon-pause").style.display = "";
btnPlay.setAttribute("aria-label", "Pause");
animFrame = requestAnimationFrame(tick);
}
function pause() {
playing = false;
playOverlay.classList.remove("hidden");
btnPlay.querySelector(".icon-play").style.display = "";
btnPlay.querySelector(".icon-pause").style.display = "none";
btnPlay.setAttribute("aria-label", "Play");
if (animFrame) cancelAnimationFrame(animFrame);
}
function togglePlay() {
playing ? pause() : play();
}
function toggleMute() {
muted = !muted;
btnMute.querySelector(".icon-volume").style.display = muted ? "none" : "";
btnMute.querySelector(".icon-muted").style.display = muted ? "" : "none";
btnMute.setAttribute("aria-label", muted ? "Unmute" : "Mute");
}
function toggleCaptions() {
captionsEnabled = !captionsEnabled;
btnCaptions.setAttribute("aria-pressed", String(captionsEnabled));
updateCaption();
}
function seek(seconds) {
currentTime = Math.max(0, Math.min(TOTAL_DURATION, currentTime + seconds));
drawCanvas();
updateUI();
}
// Event listeners
playOverlay.addEventListener("click", togglePlay);
btnPlay.addEventListener("click", togglePlay);
btnMute.addEventListener("click", toggleMute);
btnCaptions.addEventListener("click", toggleCaptions);
btnSettings.addEventListener("click", () => {
const isHidden = settingsPanel.hidden;
settingsPanel.hidden = !isHidden;
});
// Progress bar seeking
progressBar.addEventListener("click", (e) => {
const rect = progressBar.getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width;
currentTime = pct * TOTAL_DURATION;
drawCanvas();
updateUI();
});
// Keyboard controls
player.addEventListener("keydown", (e) => {
switch (e.key) {
case " ":
case "k":
case "K":
e.preventDefault();
togglePlay();
break;
case "m":
case "M":
e.preventDefault();
toggleMute();
break;
case "ArrowLeft":
e.preventDefault();
seek(-5);
break;
case "ArrowRight":
e.preventDefault();
seek(5);
break;
case "f":
case "F":
e.preventDefault();
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
player.requestFullscreen().catch(() => {});
}
break;
case "c":
case "C":
e.preventDefault();
toggleCaptions();
break;
}
});
// Caption settings
capFontSize.addEventListener("input", () => {
const val = capFontSize.value;
capFontSizeVal.textContent = val + "px";
captionText.style.fontSize = val + "px";
});
capBgOpacity.addEventListener("input", () => {
const val = capBgOpacity.value;
capBgOpacityVal.textContent = val + "%";
captionText.style.background = `rgba(0,0,0,${val / 100})`;
});
capColors.forEach((btn) => {
btn.addEventListener("click", () => {
capColors.forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
captionText.style.color = btn.dataset.color;
});
});
posBtns.forEach((btn) => {
btn.addEventListener("click", () => {
posBtns.forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
captionsEl.dataset.pos = btn.dataset.pos;
if (btn.dataset.pos === "top") {
captionsEl.style.bottom = "auto";
captionsEl.style.top = "20px";
} else {
captionsEl.style.top = "";
captionsEl.style.bottom = "60px";
}
});
});
// Initial draw
drawCanvas();
updateUI();
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Accessible Media Captions</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="container">
<h1 class="page-title">Accessible Media Captions</h1>
<p class="page-desc">Keyboard shortcuts: <kbd>Space</kbd>/<kbd>K</kbd> Play/Pause · <kbd>M</kbd> Mute · <kbd>←</kbd><kbd>→</kbd> Seek · <kbd>F</kbd> Fullscreen · <kbd>C</kbd> Captions</p>
<!-- Player -->
<div class="player" id="player" tabindex="0" role="region" aria-label="Video player">
<!-- Simulated video area -->
<div class="player__viewport">
<canvas class="player__canvas" id="canvas" width="800" height="450"></canvas>
<!-- Caption overlay -->
<div class="player__captions" id="captions" aria-live="polite" aria-atomic="true">
<span class="caption-text" id="caption-text"></span>
</div>
<!-- Play overlay -->
<button class="player__play-overlay" id="play-overlay" aria-label="Play">
<svg width="64" height="64" viewBox="0 0 64 64" fill="none">
<circle cx="32" cy="32" r="31" stroke="currentColor" stroke-width="2" opacity="0.4"/>
<polygon points="26,20 26,44 46,32" fill="currentColor"/>
</svg>
</button>
</div>
<!-- Controls bar -->
<div class="player__controls">
<!-- Progress -->
<div class="progress-bar" id="progress-bar" role="slider" aria-label="Seek" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" tabindex="0">
<div class="progress-bar__fill" id="progress-fill"></div>
</div>
<div class="controls-row">
<!-- Left controls -->
<div class="controls-left">
<button class="ctrl-btn" id="btn-play" aria-label="Play" title="Play (K)">
<svg class="icon-play" width="20" height="20" viewBox="0 0 20 20" fill="none">
<polygon points="5,3 5,17 17,10" fill="currentColor"/>
</svg>
<svg class="icon-pause" width="20" height="20" viewBox="0 0 20 20" fill="none" style="display:none">
<rect x="4" y="3" width="4" height="14" rx="1" fill="currentColor"/>
<rect x="12" y="3" width="4" height="14" rx="1" fill="currentColor"/>
</svg>
</button>
<button class="ctrl-btn" id="btn-mute" aria-label="Mute" title="Mute (M)">
<svg class="icon-volume" width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M3 7h3l4-4v14l-4-4H3V7z" fill="currentColor"/>
<path d="M14 6.5c1 1 1 5 0 7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M16 4.5c2 2.5 2 8 0 11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<svg class="icon-muted" width="20" height="20" viewBox="0 0 20 20" fill="none" style="display:none">
<path d="M3 7h3l4-4v14l-4-4H3V7z" fill="currentColor"/>
<line x1="14" y1="7" x2="19" y2="13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="19" y1="7" x2="14" y2="13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<div class="volume-slider">
<input type="range" id="volume" class="volume-range" min="0" max="100" value="75" aria-label="Volume" />
</div>
<span class="time-display" id="time-display">0:00 / 2:30</span>
</div>
<!-- Right controls -->
<div class="controls-right">
<button class="ctrl-btn" id="btn-captions" aria-label="Toggle captions" aria-pressed="true" title="Captions (C)">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<rect x="1" y="4" width="18" height="12" rx="2" stroke="currentColor" stroke-width="1.5"/>
<text x="5" y="13" font-size="8" font-weight="bold" fill="currentColor">CC</text>
</svg>
</button>
<button class="ctrl-btn" id="btn-settings" aria-label="Caption settings" title="Settings">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<circle cx="10" cy="10" r="3" stroke="currentColor" stroke-width="1.5"/>
<path d="M10 1v3M10 16v3M1 10h3M16 10h3M3.5 3.5l2 2M14.5 14.5l2 2M3.5 16.5l2-2M14.5 5.5l2-2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
</div>
</div>
<!-- Caption settings panel -->
<div class="settings-panel" id="settings-panel" hidden>
<h3 class="settings-panel__title">Caption Settings</h3>
<div class="setting">
<label class="setting__label" for="cap-font-size">Font Size</label>
<div class="setting__row">
<input type="range" id="cap-font-size" class="setting__range" min="12" max="36" value="18" />
<span class="setting__value" id="cap-font-size-val">18px</span>
</div>
</div>
<div class="setting">
<label class="setting__label" for="cap-color">Text Color</label>
<div class="setting__colors">
<button class="cap-color active" data-color="#ffffff" style="background:#fff" title="White"></button>
<button class="cap-color" data-color="#fde68a" style="background:#fde68a" title="Yellow"></button>
<button class="cap-color" data-color="#6ee7b7" style="background:#6ee7b7" title="Green"></button>
<button class="cap-color" data-color="#93c5fd" style="background:#93c5fd" title="Blue"></button>
</div>
</div>
<div class="setting">
<label class="setting__label" for="cap-bg-opacity">Background Opacity</label>
<div class="setting__row">
<input type="range" id="cap-bg-opacity" class="setting__range" min="0" max="100" value="70" />
<span class="setting__value" id="cap-bg-opacity-val">70%</span>
</div>
</div>
<div class="setting">
<label class="setting__label">Position</label>
<div class="setting__positions">
<button class="pos-btn" data-pos="top" title="Top">Top</button>
<button class="pos-btn active" data-pos="bottom" title="Bottom">Bottom</button>
</div>
</div>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>A custom media player with fully keyboard-accessible controls and synchronized captions rendered over a simulated video canvas. Users can customise caption font size, color, background opacity, and position in real time.