Video Player
A video player for shadcn/ui, built on the native <video> element and your own tokens.

const chapters = [
{ time: 0, label: "Morning in the meadow" },
{ time: 70, label: "Big Buck Bunny wakes up" },
{ time: 150, label: "The butterfly" },
{ time: 230, label: "Frank, Rinky and Gamera" },
{ time: 330, label: "The ambush" },
{ time: 460, label: "Revenge" },
{ time: 560, label: "Credits" },
]
<VideoCn
src="https://archive.org/download/BigBuckBunny_124/Content/big_buck_bunny_720p_surround.mp4"
poster="/examples/big-buck-bunny-poster.jpg"
chapters={chapters}
/>Installation
There is a single registry item, @videocn/player. The CLI copies its source into your project: the code is yours.
1. Declare the registry
In your project's components.json:
{
"registries": {
"@videocn": "https://videocn.dev/r/{name}.json"
}
}2. Install the player
pnpm dlx shadcn@latest add @videocn/playerOr skip step 1 and install straight from the URL:
pnpm dlx shadcn@latest add https://videocn.dev/r/player.jsonWhat the CLI adds
- The player files, in
components/ui/video-player/(youruialias). - The npm dependencies
shaka-player,cnandlucide-react. - The shadcn
buttoncomponent, if you don't have it yet. - Two CSS variables,
--player-scrimand--player-backdrop, for the gradient under the controls and the frame around the video.
Usage
Import the component and give it a source.
import { VideoCn } from "@/components/ui/video-player/video-cn"
export function Demo() {
return <VideoCn src="/clip.mp4" />
}Adjust the import path if your ui alias differs. Everything else is a prop: you never have to edit the files you received.
Examples
Chapters
Pass a list of { time, label }, with time in seconds. Chapters split the progress bar into segments and fill a menu.

const chapters = [
{ time: 0, label: "Prologue" },
{ time: 100, label: "The village" },
{ time: 210, label: "Searching for Scales" },
{ time: 350, label: "The shaman's tale" },
{ time: 490, label: "Crossing the mountains" },
{ time: 640, label: "The lair" },
{ time: 770, label: "The duel" },
{ time: 830, label: "Credits" },
]
<VideoCn
src="https://archive.org/download/Sintel/sintel-2048-stereo.mp4"
poster="/examples/sintel-poster.jpg"
chapters={chapters}
/>- A chapter ends where the next one starts. The last one ends with the video.
- The first chapter always starts at 0, whatever time you give it.
- Order does not matter. Duplicate times keep the first entry. Empty labels and times outside the video are dropped.
- Chapters have no effect on a live stream.
Without chapters, the chapter menu is not rendered at all. controls.chapters: false removes the menu, and controls.scrubber: { chapters: false } keeps the bar in one piece while leaving the menu in place.
Streaming (HLS)
HLS (.m3u8) and DASH (.mpd) go through Shaka Player, loaded with a dynamic import() only when such a source is played. MP4 and WebM stay on the native element.
Loads Shaka Player, 262 kB gzipped
<VideoCn
src="https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8"
type="hls"
/>The type is detected from the URL extension, ignoring the query string and hash; anything else is treated as native. It is set explicitly above for the sake of the example. You only need type when the URL is signed or has no extension:
<VideoCn
src="https://cdn.example.com/stream?token=abc123"
type="hls"
/>The Quality entry of the settings menu lists one choice per resolution. In automatic mode, it shows what is actually playing, for example Auto (720p). On the native engine, the browser exposes no levels, so the entry stays visible but greyed out.
Live
On a live stream, the progress bar covers the window the stream still serves, so you can seek back inside it. The time display shows how far behind the live edge you are, as −0:42. The Live badge is solid at the edge, dimmed when you are behind, and one click brings you back.
Loads Shaka Player, 262 kB gzipped
<VideoCn src="https://demo.unified-streaming.com/k8s/live/stable/scte35.isml/.m3u8" />Poster
An image shown until playback starts. Without one, the player shows the first frame, often black.

<VideoCn
src="https://archive.org/download/Tears-of-Steel/tears_of_steel_1080p.webm"
poster="/examples/tears-of-steel-poster.jpg"
/>Configured controls
No Picture-in-Picture, three speeds instead of seven, a bar that hides after one second, no keyboard shortcuts, and chapters kept out of the progress bar.

<VideoCn
src="https://archive.org/download/BigBuckBunny_124/Content/big_buck_bunny_720p_surround.mp4"
poster="/examples/big-buck-bunny-poster.jpg"
chapters={chapters}
controls={{
pictureInPicture: false,
playbackRate: { rates: [1, 1.5, 2] },
autoHideDelay: 1000,
keyboard: false,
scrubber: { chapters: false },
}}
/>Square corners
The radius follows --radius everywhere: frame, menus, progress bar, volume slider and Live badge. Here it is set on a wrapper; in your app, set it on :root.

:root {
--radius: 0;
}<VideoCn
src="https://archive.org/download/BigBuckBunny_124/Content/big_buck_bunny_720p_surround.mp4"
poster="/examples/big-buck-bunny-poster.jpg"
chapters={chapters}
/>Some shadcn styles, such as lyra and sera, hard-code square corners in each component instead of setting --radius. With those styles, set it yourself. The rest of your project does not read it, so nothing else changes.
Custom palette
The player has no colors of its own: these are your theme tokens. Change them and the progress bar, focus rings and menus follow, in light and dark mode.

:root {
--primary: oklch(0.488 0.243 264.376);
--primary-foreground: oklch(0.97 0.014 254.604);
--accent: oklch(0.932 0.032 255.585);
--accent-foreground: oklch(0.379 0.146 265.522);
--ring: oklch(0.623 0.214 259.815);
}
.dark {
--primary: oklch(0.623 0.214 259.815);
--primary-foreground: oklch(0.97 0.014 254.604);
--accent: oklch(0.379 0.146 265.522);
--accent-foreground: oklch(0.97 0.014 254.604);
--ring: oklch(0.488 0.243 264.376);
}<VideoCn
src="https://archive.org/download/BigBuckBunny_124/Content/big_buck_bunny_720p_surround.mp4"
poster="/examples/big-buck-bunny-poster.jpg"
chapters={chapters}
/>API Reference
Props
<VideoCn> takes no children. The control bar is always the player's own, driven by the props below.
| Prop | Type | Default | Description |
|---|---|---|---|
src | string | — | The video URL. Required. |
type | "native" | "hls" | "dash" | detected | Overrides source detection. Use it for signed URLs or URLs without an extension. See Streaming. |
poster | string | — | Image shown until playback starts. |
chapters | { time: number; label: string }[] | — | Start time in seconds and title. See Chapters. |
autoPlay | boolean | false | Passed to the video element. Browsers usually require it to be muted. |
loop | boolean | false | Passed to the video element. |
defaultVolume | number | 0.5 | Initial volume, from 0 to 1. The player owns it afterwards. |
defaultMuted | boolean | false | Start muted. Initial value only. |
controls | ControlsOptions | — | What the control bar shows and how it behaves. See Controls. |
className | string | — | Classes for the player container. |
ref | Ref<HTMLVideoElement> | — | Forwarded to the inner video element. |
Controls
Every option of controls is boolean | options. Missing or true, the control is there with its defaults. false, it is gone. An object, it is there and configured.
| Option | Type | Default | Description |
|---|---|---|---|
visibility | "auto" | "always" | "never" | "auto" | auto shows the bar on activity and hides it during playback. always never hides it. never renders no bar at all. |
autoHideDelay | number | 3000 | Idle time before the bar hides, in milliseconds. |
play | boolean | true | The play / pause button. |
scrubber | boolean | { chapters?: boolean } | true | The progress bar. { chapters: false } keeps it in one piece and leaves the chapter menu alone. |
time | boolean | true | The 0:42 / 9:56 time display. |
volume | boolean | true | Mute button and volume slider. The slider folds behind the icon and unfolds on hover or keyboard focus; on touch devices only the button shows. |
fullscreen | boolean | true | The fullscreen button. |
pictureInPicture | boolean | true | The Picture-in-Picture button. |
chapters | boolean | true | The chapter menu. It only shows when the video has chapters. |
playbackRate | boolean | { rates?: number[] } | [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2] | The Speed entry of the settings menu. Rates are sorted and deduplicated; invalid values are dropped. |
quality | boolean | true | The Quality entry of the settings menu. Greyed out, not hidden, when the engine exposes no levels. With playbackRate and quality both off, the settings button disappears. |
live | boolean | true | The Live badge. Only shown on a live stream. |
keyboard | boolean | true | Keyboard shortcuts, active when the player has focus. |
Keyboard shortcuts
Active once the player has focus: one click on the picture is enough. On a focused slider, seek or volume, the arrows drive that slider.
| Keys | Action |
|---|---|
| SpaceorK | Play / pause |
| ←/→ | Seek back / forward 5 s |
| ↑/↓ | Volume up / down 5% |
| M | Mute / unmute |
| F | Fullscreen |
| 0–9 | Jump to 0%, 10%… 90% of the video |
Shortcuts are ignored while focus is in an input, a textarea, a select or an editable element. If your app already has its own, turn them off with controls={{ keyboard: false }}.
Theming
The player reads your shadcn tokens. It has no hard-coded colors, so light and dark mode work without configuration. See Square corners and Custom palette.
Two variables are the player's own, added by the CLI: --player-scrim for the gradient under the controls and --player-backdrop for the frame around the video.
Bundle size
Measured, not estimated.
| Part | Gzip | Minified | Loaded |
|---|---|---|---|
| Player core | 14 kB | 41 kB | Always |
| Shaka Player | 262 kB | 810 kB | Only for HLS and DASH |
The core excludes React, lucide-react, cn and the shadcn button, which your app already ships. Shaka is a separate chunk, never downloaded for an MP4.
shaka-player is still listed in the dependencies, so it is installed in every project. This is deliberate: a dynamic import of a missing package breaks the build, not just the runtime. It sits in node_modules, not in your bundle.
Accessibility
- The progress bar and volume are
role="slider"elements with a label and a readable value, such as a time or a percentage. - Every button has a label. Play, mute and fullscreen announce their shortcut with
aria-keyshortcuts. - A click on the video gives the player focus without adding it to the tab order, and without drawing an outline. Keyboard focus inside the bar keeps its focus ring.
Subtitles and a most-replayed heatmap are not available yet. See what's coming in the changelog.