Video player
A video player with its own calm controls, captions, keyboard shortcuts and a poster that can be any drawing or image.
One of 5 components in Media
Details
What Video player brings along, read from the registry and the source.
- Category
- Media
- Area
- Elements
- Variants
- 4
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/video-player.tsx
- Brings along
- utilskiticon
- npm packages
- None
- Languages
- English and German, 19 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The VideoPlayerProps interface from components/ui/video-player.tsx, with the comments from the code.
srcoptionalstringVideo file. Without it the player runs as a preview: the poster stays and play simulates progress.
posteroptionalReact.ReactNodePoster as any node, e.g. a drawn scene or an <img>. Shown until the video plays.
titleoptionalstringTitle for the accessible name and the label on the untouched poster
durationoptionalnumberLength in seconds when there is no src (or before the metadata loads). Default 90.
initialTimeoptionalnumberStart position in seconds
tracksoptionalVideoTrack[]WebVTT files, rendered by the browser. The captions button appears when tracks or cues exist.
cuesoptionalCaptionCue[]Captions as data, rendered by the player itself. Works in preview mode, too.
defaultCaptionsoptionalbooleanCaptions on from the start
controlsoptional'full' | 'minimal'full: scrubber row and all buttons. minimal: one row with play, position, time and full screen.
aspectoptional'video' | 'wide' | 'square' | 'portrait'video 16:9, wide 21:9, square, portrait 9:16
mutedoptionalbooleanloopoptionalbooleananimateoptionalbooleanPoster settles while the player scrolls in, the play button pulses until the first play. Default true.
Built-in texts
Video player writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 19 texts
| Key | English | German |
|---|---|---|
| player | English Video player | German Videoplayer |
| play | English Play | German Abspielen |
| pause | English Pause | German Pausieren |
| replay | English Play again | German Noch einmal abspielen |
| mute | English Mute | German Ton aus |
| unmute | English Unmute | German Ton an |
| volume | English Volume | German Lautstärke |
| seek | English Position | German Position |
| fullscreen | English Full screen | German Vollbild |
| exitFullscreen | English Exit full screen | German Vollbild beenden |
| captionsOn | English Show captions | German Untertitel einblenden |
| captionsOff | English Hide captions | German Untertitel ausblenden |
| of | English of | German von |
| loading | English Loading | German Wird geladen |
| error | English The video could not be loaded. | German Das Video ließ sich nicht laden. |
| minute | English minute | German Minute |
| minutes | English minutes | German Minuten |
| second | English second | German Sekunde |
| seconds | English seconds | German Sekunden |
Accessibility
Read from the source of the component.
- Video player handles the keys Arrow up, Arrow down, Arrow left, Arrow right, Home, End and Space itself.
- Sets aria-label, aria-live, aria-orientation, aria-pressed, aria-valuemax, aria-valuemin, aria-valuenow and aria-valuetext, so screen readers announce purpose and state.
- Uses the roles alert, region, slider and status.
- Announces changes through a live region without moving focus.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Moves with the scroll, its progress follows the position on the page.
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 10 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 757 lines
'use client';
import { useCallback, useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
const TEXTS = {
en: {
player: 'Video player',
play: 'Play',
pause: 'Pause',
replay: 'Play again',
mute: 'Mute',
unmute: 'Unmute',
volume: 'Volume',
seek: 'Position',
fullscreen: 'Full screen',
exitFullscreen: 'Exit full screen',
captionsOn: 'Show captions',
captionsOff: 'Hide captions',
of: 'of',
loading: 'Loading',
error: 'The video could not be loaded.',
minute: 'minute',
minutes: 'minutes',
second: 'second',
seconds: 'seconds',
},
de: {
player: 'Videoplayer',
play: 'Abspielen',
pause: 'Pausieren',
replay: 'Noch einmal abspielen',
mute: 'Ton aus',
unmute: 'Ton an',
volume: 'Lautstärke',
seek: 'Position',
fullscreen: 'Vollbild',
exitFullscreen: 'Vollbild beenden',
captionsOn: 'Untertitel einblenden',
captionsOff: 'Untertitel ausblenden',
of: 'von',
loading: 'Wird geladen',
error: 'Das Video ließ sich nicht laden.',
minute: 'Minute',
minutes: 'Minuten',
second: 'Sekunde',
seconds: 'Sekunden',
},
};
type Texts = (typeof TEXTS)['en'];
/** 65 -> "1:05", 3725 -> "1:02:05" */
export function formatMediaTime(seconds: number) {
const s = Math.max(0, Math.floor(seconds || 0));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const ss = String(s % 60).padStart(2, '0');
return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;
}
/** Spoken time for screen readers, "1 minute 5 seconds". Built by hand, Intl.DurationFormat differs between server and browser. */
function spokenTime(seconds: number, t: Texts) {
const s = Math.max(0, Math.floor(seconds || 0));
const m = Math.floor(s / 60);
const r = s % 60;
const parts: string[] = [];
if (m) parts.push(`${m} ${m === 1 ? t.minute : t.minutes}`);
if (r || !m) parts.push(`${r} ${r === 1 ? t.second : t.seconds}`);
return parts.join(' ');
}
const intlLocale = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
/* ------------------------------------------------------------------ */
/* Motion pieces */
/* ------------------------------------------------------------------ */
const CSS = `
@keyframes kitfx-video-pulse{0%{opacity:.55;transform:scale(1)}70%,100%{opacity:0;transform:scale(1.55)}}
.kitfx-video-pulse{animation:kitfx-video-pulse 2.6s cubic-bezier(.2,.7,.2,1) infinite}
.kitfx-video-morph{transition:d .32s var(--ease-kit)}
@media (prefers-reduced-motion:reduce){.kitfx-video-pulse{animation:none;opacity:0}.kitfx-video-morph{transition:none}}
`;
/* Play and pause as two shapes each with the same points, so one morphs into the other */
const SHAPES = {
play: ['M7 4.5L13.25 8.25L13.25 15.75L7 19.5Z', 'M13.25 8.25L19.5 12L19.5 12L13.25 15.75Z'],
pause: ['M6 5L10 5L10 19L6 19Z', 'M14 5L18 5L18 19L14 19Z'],
};
/** Play turns into pause and back. Browsers without path transitions switch at once. */
function PlayPause({ playing, size = 18 }: { playing: boolean; size?: number }) {
const d = playing ? SHAPES.pause : SHAPES.play;
return (
<svg aria-hidden width={size} height={size} viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round">
<path className="kitfx-video-morph" d={d[0]} />
<path className="kitfx-video-morph" d={d[1]} />
</svg>
);
}
/**
* Progress of the player on its way into view, 0 when its top meets the
* bottom of the viewport, 1 once its middle reaches the middle. Written to
* --kitvp-p, so the poster settles without a render per frame. 1 on the
* server and with reduced motion.
*/
function useScrollIn<T extends HTMLElement>(enabled = true) {
const ref = useRef<T>(null);
useEffect(() => {
const el = ref.current;
if (!enabled || !el || prefersReducedMotion()) return;
let frame = 0;
const update = () => {
frame = 0;
const r = el.getBoundingClientRect();
const p = clamp((window.innerHeight - r.top) / (window.innerHeight / 2 + r.height / 2), 0, 1);
el.style.setProperty('--kitvp-p', p.toFixed(4));
};
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(update);
};
update();
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
};
}, [enabled]);
return ref;
}
/* ------------------------------------------------------------------ */
/* A slider track, used for the scrubber and the volume */
/* ------------------------------------------------------------------ */
interface TrackProps {
/** 0 to 1 */
value: number;
/** 0 to 1, drawn behind the value */
buffered?: number;
onChange: (fraction: number) => void;
onKey: (e: React.KeyboardEvent<HTMLDivElement>) => void;
label: string;
valueNow: number;
valueMax: number;
valueText: string;
/** Text in the bubble above the pointer, e.g. the time under the cursor */
hoverText?: (fraction: number) => string;
/** Keep a 44 px hit area on phones */
tall?: boolean;
className?: string;
}
function Track({ value, buffered, onChange, onKey, label, valueNow, valueMax, valueText, hoverText, tall, className }: TrackProps) {
const ref = useRef<HTMLDivElement>(null);
const [drag, setDrag] = useState(false);
const [hover, setHover] = useState<number | null>(null);
// Between updates the fill glides, while dragging it follows the pointer directly
const glide = drag ? '' : 'transition-transform duration-200 ease-linear motion-reduce:transition-none';
const fractionAt = (clientX: number) => {
const r = ref.current!.getBoundingClientRect();
return r.width ? clamp((clientX - r.left) / r.width, 0, 1) : 0;
};
return (
<div
ref={ref}
role="slider"
tabIndex={0}
aria-label={label}
aria-valuemin={0}
aria-valuemax={valueMax}
aria-valuenow={valueNow}
aria-valuetext={valueText}
aria-orientation="horizontal"
onKeyDown={onKey}
onPointerDown={(e) => {
if (e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
setDrag(true);
onChange(fractionAt(e.clientX));
}}
onPointerMove={(e) => {
if (e.pointerType === 'mouse') setHover(fractionAt(e.clientX));
if (drag) onChange(fractionAt(e.clientX));
}}
onPointerUp={() => setDrag(false)}
onPointerCancel={() => setDrag(false)}
onPointerLeave={() => setHover(null)}
className={cn(
'group/track relative flex cursor-pointer touch-none items-center rounded-md outline-offset-0',
tall ? 'h-11 @lg/player:h-6' : 'h-11 sm:h-9',
className,
)}
>
<span
data-clip-ok
className={cn(
'relative h-1 w-full overflow-hidden rounded-full bg-[color-mix(in_oklch,var(--foreground)_16%,transparent)] transition-[height] duration-150 group-hover/track:h-1.5 motion-reduce:transition-none',
drag && 'h-1.5',
)}
>
{/* Fills slide in from the left (transform), so they glide between updates and keep their round end */}
{buffered !== undefined && (
<span className={cn('absolute inset-0 rounded-full bg-[color-mix(in_oklch,var(--foreground)_24%,transparent)]', glide)} style={{ transform: `translateX(${round((buffered - 1) * 100, 3)}%)` }} />
)}
{hover !== null && hoverText && (
<span className="absolute inset-0 rounded-full bg-[color-mix(in_oklch,var(--foreground)_12%,transparent)]" style={{ transform: `translateX(${round((hover - 1) * 100, 3)}%)` }} />
)}
<span className={cn('absolute inset-0 rounded-full bg-primary', glide)} style={{ transform: `translateX(${round((value - 1) * 100, 3)}%)` }} />
</span>
<span aria-hidden data-clip-ok className={cn('pointer-events-none absolute inset-0', glide)} style={{ transform: `translateX(${round(value * 100, 3)}%)` }}>
<span
className={cn(
'absolute left-0 top-1/2 size-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary shadow-[0_0_0_2px_var(--background),var(--shadow-sm)] transition-transform duration-200 ease-[cubic-bezier(0.34,1.56,0.64,1)] motion-reduce:transition-none',
drag ? 'scale-125' : 'scale-100 sm:scale-0 sm:group-hover/track:scale-100 sm:group-focus-visible/track:scale-100',
)}
/>
</span>
{hover !== null && hoverText && (
<span
aria-hidden
className="pointer-events-none absolute bottom-full mb-1.5 -translate-x-1/2 rounded-md border border-border bg-popover px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-popover-foreground shadow-[var(--shadow-md)]"
style={{ left: `${round(hover * 100)}%` }}
>
{hoverText(hover)}
</span>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Player */
/* ------------------------------------------------------------------ */
export interface CaptionCue {
/** Seconds */
start: number;
end: number;
text: string;
}
export interface VideoTrack {
src: string;
srcLang: string;
label: string;
default?: boolean;
}
export interface VideoPlayerProps {
/** Video file. Without it the player runs as a preview: the poster stays and play simulates progress. */
src?: string;
/** Poster as any node, e.g. a drawn scene or an <img>. Shown until the video plays. */
poster?: React.ReactNode;
/** Title for the accessible name and the label on the untouched poster */
title?: string;
/** Length in seconds when there is no src (or before the metadata loads). Default 90. */
duration?: number;
/** Start position in seconds */
initialTime?: number;
/** WebVTT files, rendered by the browser. The captions button appears when tracks or cues exist. */
tracks?: VideoTrack[];
/** Captions as data, rendered by the player itself. Works in preview mode, too. */
cues?: CaptionCue[];
/** Captions on from the start */
defaultCaptions?: boolean;
/** full: scrubber row and all buttons. minimal: one row with play, position, time and full screen. */
controls?: 'full' | 'minimal';
/** video 16:9, wide 21:9, square, portrait 9:16 */
aspect?: 'video' | 'wide' | 'square' | 'portrait';
muted?: boolean;
loop?: boolean;
/** Poster settles while the player scrolls in, the play button pulses until the first play. Default true. */
animate?: boolean;
labels?: Partial<Texts>;
className?: string;
}
const ASPECT = { video: 'aspect-video', wide: 'aspect-[21/9]', square: 'aspect-square', portrait: 'aspect-[9/16]' } as const;
/**
* A video player with its own controls: play, scrubber with buffer, time, volume, captions and full screen.
* Keyboard: space or k plays, arrows seek and change the volume, m mutes, f goes full screen, c toggles captions.
* <VideoPlayer src="/tour.mp4" poster={<img src="/tour.jpg" alt="" />} title="Product tour" />
*/
export function VideoPlayer({
src,
poster,
title,
duration: durationProp = 90,
initialTime = 0,
tracks,
cues,
defaultCaptions = false,
controls = 'full',
aspect = 'video',
muted: mutedProp = false,
loop = false,
animate = true,
labels,
className,
}: VideoPlayerProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const id = useId();
const rootRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const [playing, setPlaying] = useState(false);
const [started, setStarted] = useState(initialTime > 0);
const [time, setTime] = useState(initialTime);
const [duration, setDuration] = useState(durationProp);
const [bufferedEnd, setBufferedEnd] = useState(0);
const [volume, setVolume] = useState(0.8);
const [muted, setMuted] = useState(mutedProp);
const [captions, setCaptions] = useState(defaultCaptions);
const [fullscreen, setFullscreen] = useState(false);
const [waiting, setWaiting] = useState(false);
const [failed, setFailed] = useState(false);
const [active, setActive] = useState(true);
const timeRef = useRef(initialTime);
const hideTimer = useRef<number | undefined>(undefined);
const lastPointer = useRef<string>('mouse');
// The slow push-in on the poster in preview mode, off with reduced motion. Read after mount, the server cannot know.
const [motionOk, setMotionOk] = useState(false);
useEffect(() => setMotionOk(!prefersReducedMotion()), []);
// The poster settles while the player scrolls in: from slightly larger and lower to its place
const posterRef = useScrollIn<HTMLDivElement>(animate);
const real = Boolean(src) && !failed;
const hasCaptions = Boolean(cues?.length || tracks?.length);
const minimal = controls === 'minimal';
/* Controls fade out after a moment of stillness while playing */
const poke = useCallback(() => {
setActive(true);
window.clearTimeout(hideTimer.current);
hideTimer.current = window.setTimeout(() => setActive(false), 2600);
}, []);
useEffect(() => () => window.clearTimeout(hideTimer.current), []);
useEffect(() => {
if (playing) poke();
}, [playing, poke]);
/* Only one kit player plays at a time */
const pauseRef = useRef<() => void>(() => {});
useEffect(() => {
pauseRef.current = pause;
});
useEffect(() => {
const onOther = (e: Event) => {
if ((e as CustomEvent<string>).detail !== id) pauseRef.current();
};
window.addEventListener('kit-media-play', onOther);
return () => window.removeEventListener('kit-media-play', onOther);
}, [id]);
/* Preview mode: a clock instead of a file */
useEffect(() => {
if (real || !playing) return;
let raf = 0;
let last = performance.now();
let acc = 0;
const tick = (now: number) => {
const dt = Math.min(0.25, (now - last) / 1000);
last = now;
acc += dt;
const next = Math.min(duration, timeRef.current + dt);
timeRef.current = next;
if (next >= duration) {
setTime(next);
if (loop) {
timeRef.current = 0;
setTime(0);
} else {
setPlaying(false);
return;
}
} else if (acc > 1 / 30) {
acc = 0;
setTime(next);
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [real, playing, duration, loop]);
/* Real media: volume, mute and caption tracks follow the state */
useEffect(() => {
const v = videoRef.current;
if (!v) return;
v.volume = volume;
v.muted = muted;
}, [volume, muted, real]);
useEffect(() => {
const v = videoRef.current;
if (!v) return;
for (const track of Array.from(v.textTracks)) track.mode = captions ? 'showing' : 'hidden';
}, [captions, real]);
/* Full screen state follows the document */
useEffect(() => {
const on = () => setFullscreen(document.fullscreenElement === rootRef.current);
document.addEventListener('fullscreenchange', on);
return () => document.removeEventListener('fullscreenchange', on);
}, []);
function play() {
window.dispatchEvent(new CustomEvent('kit-media-play', { detail: id }));
setStarted(true);
if (timeRef.current >= duration - 0.05) seek(0);
const v = videoRef.current;
if (real && v) {
v.play().catch(() => setPlaying(false));
} else {
setPlaying(true);
}
}
function pause() {
const v = videoRef.current;
if (real && v) v.pause();
setPlaying(false);
}
const toggle = () => (playing ? pause() : play());
function seek(to: number) {
const next = clamp(to, 0, duration);
timeRef.current = next;
setTime(next);
const v = videoRef.current;
if (real && v && Number.isFinite(v.duration)) v.currentTime = next;
}
const changeVolume = (v: number) => {
const next = clamp(round(v, 2), 0, 1);
setVolume(next);
setMuted(next === 0);
};
const toggleMute = () => {
if (muted && volume === 0) setVolume(0.5);
setMuted(!muted);
};
const toggleFullscreen = () => {
const el = rootRef.current;
if (!el) return;
if (document.fullscreenElement) {
document.exitFullscreen?.().catch(() => {});
} else if (el.requestFullscreen) {
el.requestFullscreen().catch(() => {});
} else {
// iOS Safari only knows full screen on the video element itself
(videoRef.current as (HTMLVideoElement & { webkitEnterFullscreen?: () => void }) | null)?.webkitEnterFullscreen?.();
}
};
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.altKey || e.ctrlKey || e.metaKey) return;
const target = e.target as HTMLElement;
const onSlider = target.getAttribute('role') === 'slider';
const onButton = target.tagName === 'BUTTON';
switch (e.key) {
case ' ':
if (onButton) return;
toggle();
break;
case 'k':
case 'K':
toggle();
break;
case 'ArrowLeft':
if (onSlider) return;
seek(timeRef.current - 5);
break;
case 'ArrowRight':
if (onSlider) return;
seek(timeRef.current + 5);
break;
case 'ArrowUp':
if (onSlider) return;
changeVolume((muted ? 0 : volume) + 0.1);
break;
case 'ArrowDown':
if (onSlider) return;
changeVolume((muted ? 0 : volume) - 0.1);
break;
case 'm':
case 'M':
toggleMute();
break;
case 'f':
case 'F':
toggleFullscreen();
break;
case 'c':
case 'C':
if (!hasCaptions) return;
setCaptions((c) => !c);
break;
default:
return;
}
e.preventDefault();
poke();
};
const seekKeys = (e: React.KeyboardEvent<HTMLDivElement>) => {
const step = { ArrowLeft: -5, ArrowDown: -5, ArrowRight: 5, ArrowUp: 5, PageDown: -duration / 10, PageUp: duration / 10 }[e.key];
if (step !== undefined) seek(timeRef.current + step);
else if (e.key === 'Home') seek(0);
else if (e.key === 'End') seek(duration);
else return;
e.preventDefault();
e.stopPropagation();
poke();
};
const volumeKeys = (e: React.KeyboardEvent<HTMLDivElement>) => {
const step = { ArrowLeft: -0.05, ArrowDown: -0.05, ArrowRight: 0.05, ArrowUp: 0.05, PageDown: -0.2, PageUp: 0.2 }[e.key];
const current = muted ? 0 : volume;
if (step !== undefined) changeVolume(current + step);
else if (e.key === 'Home') changeVolume(0);
else if (e.key === 'End') changeVolume(1);
else return;
e.preventDefault();
e.stopPropagation();
};
const progress = duration ? clamp(time / duration, 0, 1) : 0;
const buffered = real ? clamp(bufferedEnd / (duration || 1), 0, 1) : started ? clamp(progress + 0.18, 0, 1) : 0;
const cue = captions && cues ? cues.find((c) => time >= c.start && time < c.end) : undefined;
const showControls = started && (!playing || active);
const ended = !playing && started && time >= duration - 0.05;
const percent = new Intl.NumberFormat(intlLocale(locale), { style: 'percent' });
const effectiveVolume = muted ? 0 : volume;
const button = 'relative grid size-11 shrink-0 place-items-center rounded-lg text-[color-mix(in_oklch,var(--foreground)_82%,transparent)] transition-colors duration-150 hover:bg-[color-mix(in_oklch,var(--foreground)_8%,transparent)] hover:text-foreground sm:size-9';
return (
<div
ref={rootRef}
role="region"
aria-label={title ? `${t.player}, ${title}` : t.player}
tabIndex={-1}
onKeyDown={onKeyDown}
onPointerMove={(e) => {
lastPointer.current = e.pointerType;
if (playing) poke();
}}
className={cn(
'group/player @container/player relative isolate w-full overflow-hidden rounded-xl border border-border bg-surface-2 text-foreground shadow-[var(--highlight),var(--shadow-lg)] outline-none',
fullscreen ? 'rounded-none border-0' : ASPECT[aspect],
playing && !active && 'cursor-none',
className,
)}
>
{/* Picture: poster, or the video once it plays */}
<style dangerouslySetInnerHTML={{ __html: CSS }} />
<div data-clip-ok className="absolute inset-0 -z-10 overflow-hidden" aria-hidden={real && started ? undefined : true}>
{(!real || !started) && poster && (
<div ref={posterRef} className="absolute inset-0" style={{ transform: 'translate3d(0, calc((1 - var(--kitvp-p, 1)) * 4%), 0) scale(calc(1.1 - var(--kitvp-p, 1) * 0.1))' }}>
<div
className="absolute inset-0 transition-transform duration-500 ease-linear motion-reduce:transition-none [&>*]:size-full"
style={!real && started && motionOk ? { transform: `scale(${round(1 + progress * 0.06, 4)})`, transformOrigin: '50% 45%' } : undefined}
>
{poster}
</div>
</div>
)}
{real && (
<video
ref={videoRef}
src={src}
playsInline
preload="metadata"
loop={loop}
muted={mutedProp}
className={cn('absolute inset-0 size-full object-cover', !started && 'opacity-0')}
onLoadedMetadata={(e) => {
const d = e.currentTarget.duration;
if (Number.isFinite(d)) setDuration(d);
if (initialTime) e.currentTarget.currentTime = initialTime;
}}
onTimeUpdate={(e) => {
timeRef.current = e.currentTarget.currentTime;
setTime(e.currentTarget.currentTime);
}}
onProgress={(e) => {
const b = e.currentTarget.buffered;
if (b.length) setBufferedEnd(b.end(b.length - 1));
}}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => setPlaying(false)}
onWaiting={() => setWaiting(true)}
onPlaying={() => setWaiting(false)}
onCanPlay={() => setWaiting(false)}
onError={() => {
setFailed(true);
setPlaying(false);
}}
>
{tracks?.map((tr) => (
<track key={tr.src} kind="captions" src={tr.src} srcLang={tr.srcLang} label={tr.label} default={tr.default} />
))}
</video>
)}
</div>
{/* Click surface: play and pause, double click for full screen */}
<div
aria-hidden
className="absolute inset-0"
onClick={() => {
if (lastPointer.current === 'touch' && playing && !active) {
poke();
return;
}
toggle();
}}
onDoubleClick={toggleFullscreen}
/>
{/* Title on the untouched poster */}
{title && (
<div
aria-hidden={started || undefined}
className={cn(
'pointer-events-none absolute inset-x-0 top-0 flex items-start justify-between gap-3 bg-gradient-to-b from-[color-mix(in_oklch,var(--background)_70%,transparent)] to-transparent p-4 pb-10 transition-[opacity,transform] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none @lg/player:p-5 @lg/player:pb-12',
started && '-translate-y-2 opacity-0',
)}
>
<p className="min-w-0 truncate text-[13px] font-semibold tracking-[-0.01em] @lg/player:text-sm">{title}</p>
<span className="shrink-0 rounded-md bg-[color-mix(in_oklch,var(--background)_78%,transparent)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-muted-foreground backdrop-blur-md">
{formatMediaTime(duration)}
</span>
</div>
)}
{/* Big play button */}
<button
type="button"
onClick={play}
aria-label={ended ? t.replay : t.play}
tabIndex={playing ? -1 : 0}
aria-hidden={playing || undefined}
className={cn(
'absolute left-1/2 top-1/2 grid -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border border-[color-mix(in_oklch,var(--foreground)_12%,transparent)] bg-[color-mix(in_oklch,var(--background)_70%,transparent)] text-foreground shadow-[var(--highlight),var(--shadow-lg)] backdrop-blur-xl transition-[opacity,transform,background-color] duration-300 ease-[var(--ease-kit)] hover:scale-[1.04] hover:bg-[color-mix(in_oklch,var(--background)_82%,transparent)] motion-reduce:transition-none motion-reduce:hover:scale-100',
started ? 'size-14 @lg/player:size-16' : 'size-16 @lg/player:size-20',
'active:scale-95',
playing && 'pointer-events-none scale-75 opacity-0',
)}
>
{/* A quiet pulse invites the first play, it stops once the video ran */}
{!started && animate && <span aria-hidden className="kitfx-video-pulse pointer-events-none absolute -inset-px rounded-full border border-[color-mix(in_oklch,var(--foreground)_40%,transparent)]" />}
{ended ? (
<Icon name="rotate-ccw" size={24} strokeWidth={2} />
) : (
<Icon name="play" size={started ? 22 : 28} fill="currentColor" strokeWidth={1.2} className="translate-x-[2px]" />
)}
</button>
{/* Buffering and errors with a real file */}
{real && waiting && playing && (
<span role="status" aria-label={t.loading} className="pointer-events-none absolute left-1/2 top-1/2 size-10 -translate-x-1/2 -translate-y-1/2 animate-spin rounded-full border-2 border-[color-mix(in_oklch,var(--foreground)_20%,transparent)] border-t-foreground motion-reduce:animate-none" />
)}
{failed && (
<p role="alert" className="absolute inset-x-4 top-4 rounded-lg bg-[color-mix(in_oklch,var(--background)_82%,transparent)] px-3 py-2 text-center text-[13px] text-foreground backdrop-blur-md">
{t.error}
</p>
)}
{/* Captions drawn by the player */}
{cue && (
<div
className={cn(
'pointer-events-none absolute inset-x-4 flex justify-center transition-[bottom] duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none',
showControls ? (minimal ? 'bottom-[4.25rem] @lg/player:bottom-[4.5rem]' : 'bottom-[4.25rem] @lg/player:bottom-[5.75rem]') : 'bottom-5',
)}
>
<p aria-live="off" className="max-w-[40rem] text-balance rounded-md bg-[color-mix(in_oklch,var(--background)_84%,transparent)] px-2.5 py-1 text-center text-[13px] font-medium leading-snug text-foreground backdrop-blur-md @lg/player:text-[17px]">
{cue.text}
</p>
</div>
)}
{/* Control bar */}
<div
className={cn(
'absolute inset-x-2 bottom-2 flex flex-wrap items-center rounded-xl border border-[color-mix(in_oklch,var(--foreground)_10%,transparent)] bg-[color-mix(in_oklch,var(--background)_76%,transparent)] px-1 shadow-[var(--highlight),var(--shadow-md)] backdrop-blur-xl transition-[opacity,transform] duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none @lg/player:inset-x-3 @lg/player:bottom-3',
minimal ? 'gap-x-0.5' : 'gap-x-0.5 @lg/player:px-2 @lg/player:pb-1',
showControls
? 'translate-y-0 opacity-100'
: 'pointer-events-none translate-y-2 opacity-0 group-focus-visible/player:pointer-events-auto group-focus-visible/player:translate-y-0 group-focus-visible/player:opacity-100 group-has-[:focus-visible]/player:pointer-events-auto group-has-[:focus-visible]/player:translate-y-0 group-has-[:focus-visible]/player:opacity-100',
)}
>
<button type="button" onClick={toggle} aria-label={playing ? t.pause : ended ? t.replay : t.play} className={cn(button, 'order-1')}>
<PlayPause playing={playing} size={18} />
</button>
<Track
tall={!minimal}
value={progress}
buffered={buffered}
onChange={(f) => {
seek(f * duration);
poke();
}}
onKey={seekKeys}
label={t.seek}
valueNow={Math.floor(time)}
valueMax={Math.floor(duration)}
valueText={`${spokenTime(time, t)} ${t.of} ${spokenTime(duration, t)}`}
hoverText={(f) => formatMediaTime(f * duration)}
className={cn('order-2 mx-1.5 min-w-0 flex-1', !minimal && '@lg/player:order-none @lg/player:basis-full')}
/>
<span className={cn('order-3 shrink-0 px-1 text-[12px] font-medium tabular-nums text-[color-mix(in_oklch,var(--foreground)_82%,transparent)] @lg/player:px-1.5', !minimal && '@lg/player:order-4')}>
{formatMediaTime(time)}
<span className="hidden text-subtle-foreground @md/player:inline"> / {formatMediaTime(duration)}</span>
</span>
<button type="button" onClick={toggleMute} aria-label={muted || volume === 0 ? t.unmute : t.mute} aria-pressed={muted || volume === 0} className={cn(button, 'order-5', !minimal && 'hidden @lg/player:order-2 @lg/player:grid')}>
<Icon name={effectiveVolume === 0 ? 'volume-x' : 'volume'} size={18} />
</button>
{!minimal && (
<Track
value={effectiveVolume}
onChange={changeVolume}
onKey={volumeKeys}
label={t.volume}
valueNow={Math.round(effectiveVolume * 100)}
valueMax={100}
valueText={percent.format(effectiveVolume)}
className="order-3 hidden w-20 @lg/player:mr-1 @lg/player:flex"
/>
)}
{!minimal && <span aria-hidden className="hidden @lg/player:order-5 @lg/player:block @lg/player:flex-1" />}
{hasCaptions && !minimal && (
<button type="button" onClick={() => setCaptions((c) => !c)} aria-label={captions ? t.captionsOff : t.captionsOn} aria-pressed={captions} className={cn(button, 'order-4 @lg/player:order-6', captions && 'text-foreground')}>
<Icon name="captions" size={18} />
<span aria-hidden className={cn('absolute bottom-1.5 left-1/2 h-0.5 w-3.5 -translate-x-1/2 rounded-full bg-primary transition-opacity sm:bottom-1', captions ? 'opacity-100' : 'opacity-0')} />
</button>
)}
<button type="button" onClick={toggleFullscreen} aria-label={fullscreen ? t.exitFullscreen : t.fullscreen} className={cn(button, 'order-6 @lg/player:order-7')}>
<Icon name={fullscreen ? 'minimize' : 'maximize'} size={17} />
</button>
</div>
</div>
);
}Frequent questions
How do I install Video player?
With aiw add @ai-website/video-player. The command puts components/ui/video-player.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.
Which variants does Video player have?
4 variants, Poster, Captions, Minimal in a card and Portrait clip.
Can I translate the texts of Video player?
Yes. Video player carries 19 texts of its own in English and German, such as “Video player”, “Play” and “Pause”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Video player?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Video player with aiw_components and install it with aiw_add @ai-website/video-player.