Skip to content
UI Kit
Accent color

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

4 variants

Variants

Poster

Untouched state with title, length and a big play button. Controls fade out while playing and return on movement or focus.

Captions

Paused mid film with captions on. Cues come as data or as WebVTT tracks, the button only appears when captions exist.

Minimal in a card

One row of controls for small players, here as a lesson in a course.

Portrait clip

A 9:16 clip for reels and stories, with the minimal controls and a post beside it.

Details

What Video player brings along, read from the registry and the source.

Category
Media
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

Props

The VideoPlayerProps interface from components/ui/video-player.tsx, with the comments from the code.

  • srcoptional
    string

    Video file. Without it the player runs as a preview: the poster stays and play simulates progress.

  • posteroptional
    React.ReactNode

    Poster as any node, e.g. a drawn scene or an <img>. Shown until the video plays.

  • titleoptional
    string

    Title for the accessible name and the label on the untouched poster

  • durationoptional
    number

    Length in seconds when there is no src (or before the metadata loads). Default 90.

  • initialTimeoptional
    number

    Start position in seconds

  • tracksoptional
    VideoTrack[]

    WebVTT files, rendered by the browser. The captions button appears when tracks or cues exist.

  • cuesoptional
    CaptionCue[]

    Captions as data, rendered by the player itself. Works in preview mode, too.

  • defaultCaptionsoptional
    boolean

    Captions 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

  • mutedoptional
    boolean
  • loopoptional
    boolean
  • animateoptional
    boolean

    Poster 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
playerEnglish Video playerGerman Videoplayer
playEnglish PlayGerman Abspielen
pauseEnglish PauseGerman Pausieren
replayEnglish Play againGerman Noch einmal abspielen
muteEnglish MuteGerman Ton aus
unmuteEnglish UnmuteGerman Ton an
volumeEnglish VolumeGerman Lautstärke
seekEnglish PositionGerman Position
fullscreenEnglish Full screenGerman Vollbild
exitFullscreenEnglish Exit full screenGerman Vollbild beenden
captionsOnEnglish Show captionsGerman Untertitel einblenden
captionsOffEnglish Hide captionsGerman Untertitel ausblenden
ofEnglish ofGerman von
loadingEnglish LoadingGerman Wird geladen
errorEnglish The video could not be loaded.German Das Video ließ sich nicht laden.
minuteEnglish minuteGerman Minute
minutesEnglish minutesGerman Minuten
secondEnglish secondGerman Sekunde
secondsEnglish secondsGerman 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
components/ui/video-player.tsx
'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.

Often searched

Jump to

Copied to the clipboard