Video Player

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

chapters
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:

components.json
{
  "registries": {
    "@videocn": "https://videocn.dev/r/{name}.json"
  }
}

2. Install the player

pnpm dlx shadcn@latest add @videocn/player

Or skip step 1 and install straight from the URL:

pnpm dlx shadcn@latest add https://videocn.dev/r/player.json

What the CLI adds

  • The player files, in components/ui/video-player/ (your ui alias).
  • The npm dependencies shaka-player, cn and lucide-react.
  • The shadcn button component, if you don't have it yet.
  • Two CSS variables, --player-scrim and --player-backdrop, for the gradient under the controls and the frame around the video.

Usage

Import the component and give it a source.

demo.tsx
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.

chapters
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.

type="hls"

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.

controls.live

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.

poster
<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.

controls
<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.

--radius: 0
globals.css
: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.

--primary
globals.css
: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.

PropTypeDefaultDescription
srcstring—The video URL. Required.
type"native" | "hls" | "dash"detectedOverrides source detection. Use it for signed URLs or URLs without an extension. See Streaming.
posterstring—Image shown until playback starts.
chapters{ time: number; label: string }[]—Start time in seconds and title. See Chapters.
autoPlaybooleanfalsePassed to the video element. Browsers usually require it to be muted.
loopbooleanfalsePassed to the video element.
defaultVolumenumber0.5Initial volume, from 0 to 1. The player owns it afterwards.
defaultMutedbooleanfalseStart muted. Initial value only.
controlsControlsOptions—What the control bar shows and how it behaves. See Controls.
classNamestring—Classes for the player container.
refRef<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.

OptionTypeDefaultDescription
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.
autoHideDelaynumber3000Idle time before the bar hides, in milliseconds.
playbooleantrueThe play / pause button.
scrubberboolean | { chapters?: boolean }trueThe progress bar. { chapters: false } keeps it in one piece and leaves the chapter menu alone.
timebooleantrueThe 0:42 / 9:56 time display.
volumebooleantrueMute button and volume slider. The slider folds behind the icon and unfolds on hover or keyboard focus; on touch devices only the button shows.
fullscreenbooleantrueThe fullscreen button.
pictureInPicturebooleantrueThe Picture-in-Picture button.
chaptersbooleantrueThe chapter menu. It only shows when the video has chapters.
playbackRateboolean | { 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.
qualitybooleantrueThe 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.
livebooleantrueThe Live badge. Only shown on a live stream.
keyboardbooleantrueKeyboard 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.

KeysAction
SpaceorKPlay / pause
←/→Seek back / forward 5 s
↑/↓Volume up / down 5%
MMute / unmute
FFullscreen
0–9Jump 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.

PartGzipMinifiedLoaded
Player core14 kB41 kBAlways
Shaka Player262 kB810 kBOnly 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.