Skip to content
Caseroom

Components · Media

Video player

A poster with one Play button; native controls and captions once it plays. Nothing plays by itself.

1.1% of the 2,062 sites in The UI Index have a media player (23 sites).

A tour of the Caseroom docs0:06

Both previews render the same markup; the HTML tab below is that markup without a framework.

Install

React
npx shadcn@latest add https://www.themes.dev/caseroom/r/video-player.json
Astro
npx shadcn@latest add https://www.themes.dev/caseroom/r/astro/video-player.json

Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom theme. The HTML needs only the compiled CSS and the script.

video-player.client.ts
// Video player behaviour, shared by the Astro, React and HTML versions: the Play button starts the video, shows the
// native controls, moves focus to the video and removes itself.
declare global {
  interface Window {
    __caseroomVideo?: boolean;
  }
}

if (typeof window !== 'undefined' && !window.__caseroomVideo) {
  window.__caseroomVideo = true;
  document.addEventListener('click', (e) => {
    const b = (e.target as Element).closest<HTMLButtonElement>('[data-video-play]');
    if (!b) return;
    const v = b.parentElement?.querySelector('video');
    if (!v) return;
    v.controls = true;
    b.remove();
    v.focus();
    void v.play().catch(() => {});
  });
}

export {};
video-player.tsx
import { cn } from '@/lib/utils';
import './video-player.client';
import { PLAY_PATH, video as c } from './video-player.variants';

export interface VideoPlayerProps {
  src: string;
  poster: string;
  title: string;
  /** e.g. "0:06" */
  duration?: string;
  /** A WebVTT captions file. */
  captions?: string;
  className?: string;
}

/** A poster with one Play button; pressing it plays the video with native controls and captions. */
export function VideoPlayer({ src, poster, title, duration, captions, className }: VideoPlayerProps) {
  return (
    <figure className={cn(c.root, className)}>
      <div className={c.frame}>
        <video src={src} poster={poster} preload="none" playsInline aria-label={title} className={c.media}>
          {captions && <track kind="captions" src={captions} srcLang="en" label="English" default />}
        </video>
        <button type="button" data-video-play="" aria-label={`Play video: ${title}`} className={c.play}>
          <span className={c.disc}><svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d={PLAY_PATH} /></svg></span>
        </button>
      </div>
      <figcaption className={c.caption}>
        <span className={c.title}>{title}</span>
        {duration && <span className={c.meta}>{duration}</span>}
      </figcaption>
    </figure>
  );
}
video-player.variants.ts
/** A video player on native <video>: a poster with one large Play button (named after the video); pressing it
 *  starts the video with the browser's own controls and the captions track. Nothing plays by itself. */
export const video = {
  root: 'w-full',
  frame: 'relative aspect-video overflow-hidden rounded-xl border bg-muted',
  media: 'size-full object-cover',
  play:
    // The ring sits inside the frame (the frame clips anything outside it).
    'group absolute inset-0 grid cursor-pointer place-items-center rounded-xl bg-black/20 outline-offset-[-4px] transition-colors hover:bg-black/30 active:bg-black/40 [&_svg]:size-6',
  disc: 'grid size-16 place-items-center rounded-full bg-background text-foreground shadow-lg transition-transform group-hover:scale-105 group-active:scale-95 motion-reduce:transition-none',
  caption: 'mt-3 flex items-baseline justify-between gap-4 text-sm',
  title: 'font-medium',
  meta: 'text-muted-foreground tabular-nums',
};
export const PLAY_PATH = 'M5.5 3.5v9l7-4.5z';