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).
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 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 {};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>
);
}/** 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';---
import { cn } from '@/lib/utils';
import { PLAY_PATH, video as c } from './video-player.variants';
interface Props {
src: string;
poster: string;
title: string;
/** e.g. "0:06" */
duration?: string;
/** A WebVTT captions file. */
captions?: string;
class?: string;
}
const { src, poster, title, duration, captions, class: className } = Astro.props;
---
<figure class={cn(c.root, className)}>
<div class={c.frame}>
<video src={src} poster={poster} preload="none" playsinline aria-label={title} class={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}`} class={c.play}>
<span class={c.disc}><svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d={PLAY_PATH} /></svg></span>
</button>
</div>
<figcaption class={c.caption}>
<span class={c.title}>{title}</span>
{duration && <span class={c.meta}>{duration}</span>}
</figcaption>
</figure>
<script>
import './video-player.client';
</script>// 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 {};/** 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';<figure class="w-full max-w-xl">
<div class="relative aspect-video overflow-hidden rounded-xl border bg-muted">
<video src="/caseroom/demo/media/demo.webm" poster="/caseroom/demo/media/poster.jpg" preload="none" playsinline aria-label="A tour of the Caseroom docs" class="size-full object-cover">
<track kind="captions" src="/caseroom/demo/media/demo.vtt" srclang="en" label="English" default>
</video>
<button type="button" data-video-play aria-label="Play video: A tour of the Caseroom docs" class="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">
<span class="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">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M5.5 3.5v9l7-4.5z"></path></svg>
</span>
</button>
</div>
<figcaption class="mt-3 flex items-baseline justify-between gap-4 text-sm">
<span class="font-medium">A tour of the Caseroom docs</span>
<span class="text-muted-foreground tabular-nums">0:06</span>
</figcaption>
</figure><script type="module" src="/caseroom/r/html/video-player.js"></script>