HextaUI

Video player

A video player with a scrubbable seek bar, auto-hiding controls, keyboard shortcuts, speed, picture in picture and full screen.

0:00 / --:--
import {
  VideoPlayer,
  VideoPlayerContent,
  VideoPlayerControls,
  VideoPlayerFullscreenButton,
  VideoPlayerPictureInPictureButton,
  VideoPlayerPlaybackRate,
  VideoPlayerPlayButton,
  VideoPlayerSeekBar,
  VideoPlayerSeekButton,
  VideoPlayerSpacer,
  VideoPlayerTime,
  VideoPlayerVolume,
} from "@/components/ui/video-player"

export function VideoPlayerDemo() {
  return (
    <VideoPlayer className="max-w-2xl">
      <VideoPlayerContent
        poster="https://media.w3.org/2010/05/sintel/poster.png"
        aria-label="Sintel trailer"
      >
        <source
          src="https://media.w3.org/2010/05/sintel/trailer.webm"
          type="video/webm"
        />
        <source
          src="https://media.w3.org/2010/05/sintel/trailer.mp4"
          type="video/mp4"
        />
      </VideoPlayerContent>
      <VideoPlayerControls>
        <VideoPlayerSeekBar />
        <VideoPlayerPlayButton />
        <VideoPlayerSeekButton offset={-10} />
        <VideoPlayerSeekButton offset={10} />
        <VideoPlayerVolume />
        <VideoPlayerTime />
        <VideoPlayerSpacer />
        <VideoPlayerPlaybackRate />
        <VideoPlayerPictureInPictureButton />
        <VideoPlayerFullscreenButton />
      </VideoPlayerControls>
    </VideoPlayer>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/video-player.json

Adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

import {
  VideoPlayer,
  VideoPlayerContent,
  VideoPlayerControls,
  VideoPlayerFullscreenButton,
  VideoPlayerPlayButton,
  VideoPlayerSeekBar,
  VideoPlayerSpacer,
  VideoPlayerTime,
  VideoPlayerVolume,
} from "@/components/ui/video-player"
<VideoPlayer>
  <VideoPlayerContent src="/intro.mp4" poster="/intro.jpg" />
  <VideoPlayerControls>
    <VideoPlayerSeekBar />
    <VideoPlayerPlayButton />
    <VideoPlayerVolume />
    <VideoPlayerTime />
    <VideoPlayerSpacer />
    <VideoPlayerFullscreenButton />
  </VideoPlayerControls>
</VideoPlayer>
VideoPlayer
├── VideoPlayerContent
└── VideoPlayerControls
    ├── VideoPlayerPlayButton
    ├── VideoPlayerSeekButton
    ├── VideoPlayerSeekBar
    ├── VideoPlayerTime
    ├── VideoPlayerSpacer
    ├── VideoPlayerVolume
    ├── VideoPlayerPlaybackRate
    ├── VideoPlayerCaptionsButton
    ├── VideoPlayerPictureInPictureButton
    └── VideoPlayerFullscreenButton

Bar

variant="bar" puts the controls under the picture on the page surface. They never hide or cover the video.

0:00 / --:--
import {
  VideoPlayer,
  VideoPlayerContent,
  VideoPlayerControls,
  VideoPlayerFullscreenButton,
  VideoPlayerPictureInPictureButton,
  VideoPlayerPlaybackRate,
  VideoPlayerPlayButton,
  VideoPlayerSeekBar,
  VideoPlayerSeekButton,
  VideoPlayerSpacer,
  VideoPlayerTime,
  VideoPlayerVolume,
} from "@/components/ui/video-player"

export function VideoPlayerBar() {
  return (
    <VideoPlayer variant="bar" className="max-w-2xl">
      <VideoPlayerContent
        poster="https://media.w3.org/2010/05/sintel/poster.png"
        aria-label="Sintel trailer"
      >
        <source
          src="https://media.w3.org/2010/05/sintel/trailer.webm"
          type="video/webm"
        />
        <source
          src="https://media.w3.org/2010/05/sintel/trailer.mp4"
          type="video/mp4"
        />
      </VideoPlayerContent>
      <VideoPlayerControls>
        <VideoPlayerSeekBar />
        <VideoPlayerPlayButton />
        <VideoPlayerSeekButton offset={-10} />
        <VideoPlayerSeekButton offset={10} />
        <VideoPlayerVolume />
        <VideoPlayerTime />
        <VideoPlayerSpacer />
        <VideoPlayerPlaybackRate />
        <VideoPlayerPictureInPictureButton />
        <VideoPlayerFullscreenButton />
      </VideoPlayerControls>
    </VideoPlayer>
  )
}

Minimal

Use only the parts you need. tooltips={false} turns off the hover hints, and type="remaining" counts down instead of up.

--:--
import {
  VideoPlayer,
  VideoPlayerContent,
  VideoPlayerControls,
  VideoPlayerFullscreenButton,
  VideoPlayerPlayButton,
  VideoPlayerSeekBar,
  VideoPlayerSpacer,
  VideoPlayerTime,
} from "@/components/ui/video-player"

export function VideoPlayerMinimal() {
  return (
    <VideoPlayer className="max-w-md">
      <VideoPlayerContent
        src="https://media.w3.org/2010/05/sintel/trailer.mp4"
        poster="https://media.w3.org/2010/05/sintel/poster.png"
        aria-label="Sintel trailer"
      />
      <VideoPlayerControls tooltips={false}>
        <VideoPlayerSeekBar />
        <VideoPlayerPlayButton />
        <VideoPlayerTime type="remaining" />
        <VideoPlayerSpacer />
        <VideoPlayerFullscreenButton />
      </VideoPlayerControls>
    </VideoPlayer>
  )
}

Seek offsets and speeds

offset sets how far each seek button jumps, and rates sets the speeds in the menu.

0:00 / --:--
import {
  VideoPlayer,
  VideoPlayerContent,
  VideoPlayerControls,
  VideoPlayerPlaybackRate,
  VideoPlayerPlayButton,
  VideoPlayerSeekBar,
  VideoPlayerSeekButton,
  VideoPlayerSpacer,
  VideoPlayerTime,
} from "@/components/ui/video-player"

export function VideoPlayerSeekOffsets() {
  return (
    <VideoPlayer className="max-w-xl">
      <VideoPlayerContent
        src="https://media.w3.org/2010/05/sintel/trailer.mp4"
        poster="https://media.w3.org/2010/05/sintel/poster.png"
        aria-label="Sintel trailer"
      />
      <VideoPlayerControls>
        <VideoPlayerSeekBar />
        <VideoPlayerSeekButton offset={-15} />
        <VideoPlayerPlayButton />
        <VideoPlayerSeekButton offset={30} />
        <VideoPlayerTime />
        <VideoPlayerSpacer />
        <VideoPlayerPlaybackRate rates={[1, 1.5, 2, 3]} />
      </VideoPlayerControls>
    </VideoPlayer>
  )
}

Page-wide shortcuts

Shortcuts work while focus is inside the player. globalShortcuts also listens on the page, but never while you type in a field, use a button or have a menu or dialog open. Use it for one player per page.

0:00 / --:--

Press K anywhere on the page to play or pause.

import { Kbd } from "@/components/ui/kbd"
import {
  VideoPlayer,
  VideoPlayerContent,
  VideoPlayerControls,
  VideoPlayerFullscreenButton,
  VideoPlayerPlayButton,
  VideoPlayerSeekBar,
  VideoPlayerSpacer,
  VideoPlayerTime,
  VideoPlayerVolume,
} from "@/components/ui/video-player"

export function VideoPlayerGlobalShortcuts() {
  return (
    <div className="flex w-full max-w-xl flex-col items-center gap-3">
      <VideoPlayer globalShortcuts>
        <VideoPlayerContent
          src="https://media.w3.org/2010/05/sintel/trailer.mp4"
          poster="https://media.w3.org/2010/05/sintel/poster.png"
          aria-label="Sintel trailer"
        />
        <VideoPlayerControls>
          <VideoPlayerSeekBar />
          <VideoPlayerPlayButton />
          <VideoPlayerVolume />
          <VideoPlayerTime />
          <VideoPlayerSpacer />
          <VideoPlayerFullscreenButton />
        </VideoPlayerControls>
      </VideoPlayer>
      <p className="text-sm text-muted-foreground">
        Press <Kbd keys="k" /> anywhere on the page to play or pause.
      </p>
    </div>
  )
}

Captions

Add a <track> and VideoPlayerCaptionsButton. Captions are drawn by the player, so they move up while the controls show instead of hiding behind them. A cross-origin track needs crossOrigin on the video.

0:00 / --:--
import {
  VideoPlayer,
  VideoPlayerCaptionsButton,
  VideoPlayerContent,
  VideoPlayerControls,
  VideoPlayerFullscreenButton,
  VideoPlayerPlayButton,
  VideoPlayerSeekBar,
  VideoPlayerSpacer,
  VideoPlayerTime,
  VideoPlayerVolume,
} from "@/components/ui/video-player"

export function VideoPlayerCaptions() {
  return (
    <VideoPlayer className="max-w-xl">
      <VideoPlayerContent
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/friday.mp4"
        crossOrigin="anonymous"
        aria-label="Friday"
      >
        <track
          default
          kind="captions"
          srcLang="en"
          label="English"
          src="https://interactive-examples.mdn.mozilla.net/media/examples/friday.vtt"
        />
      </VideoPlayerContent>
      <VideoPlayerControls>
        <VideoPlayerSeekBar />
        <VideoPlayerPlayButton />
        <VideoPlayerVolume />
        <VideoPlayerTime />
        <VideoPlayerSpacer />
        <VideoPlayerCaptionsButton />
        <VideoPlayerFullscreenButton />
      </VideoPlayerControls>
    </VideoPlayer>
  )
}

Custom controls

useVideoPlayer reads state and actions from any component inside the player. Select only what you use, so the component re-renders only when that value changes.

0:00 / --:--
"use client"

import { Button } from "@/components/ui/button"
import {
  formatTime,
  useVideoPlayer,
  VideoPlayer,
  VideoPlayerContent,
  VideoPlayerControls,
  VideoPlayerPlayButton,
  VideoPlayerSeekBar,
  VideoPlayerTime,
} from "@/components/ui/video-player"

const chapters = [
  { title: "The cave", time: 0 },
  { title: "Searching", time: 13 },
  { title: "The dragon", time: 31 },
]

function Chapters() {
  const seek = useVideoPlayer((player) => player.seek)
  const play = useVideoPlayer((player) => player.play)
  const currentTime = useVideoPlayer((player) => Math.floor(player.currentTime))
  const active = chapters.findLast((chapter) => chapter.time <= currentTime)

  return (
    <div className="flex flex-wrap gap-2">
      {chapters.map((chapter) => (
        <Button
          key={chapter.title}
          variant={chapter === active ? "secondary" : "outline"}
          size="sm"
          aria-pressed={chapter === active}
          onClick={() => {
            seek(chapter.time)
            play()
          }}
        >
          <span className="tabular-nums">{formatTime(chapter.time)}</span>
          {chapter.title}
        </Button>
      ))}
    </div>
  )
}

export function VideoPlayerCustomControls() {
  return (
    <VideoPlayer variant="bar" className="max-w-xl">
      <VideoPlayerContent
        src="https://media.w3.org/2010/05/sintel/trailer.mp4"
        poster="https://media.w3.org/2010/05/sintel/poster.png"
        aria-label="Sintel trailer"
      />
      <VideoPlayerControls>
        <VideoPlayerSeekBar />
        <VideoPlayerPlayButton />
        <VideoPlayerTime />
        <div className="basis-full px-1 pt-1">
          <Chapters />
        </div>
      </VideoPlayerControls>
    </VideoPlayer>
  )
}

Error

When the source fails, the player shows errorMessage, announces it and disables the controls that can’t work.

0:00 / --:--
import {
  VideoPlayer,
  VideoPlayerContent,
  VideoPlayerControls,
  VideoPlayerPlayButton,
  VideoPlayerSeekBar,
  VideoPlayerTime,
} from "@/components/ui/video-player"

export function VideoPlayerError() {
  return (
    <VideoPlayer
      className="max-w-md"
      errorMessage="We couldn’t load this video. Check your connection and try again."
    >
      <VideoPlayerContent
        src="https://media.w3.org/2010/05/sintel/missing.mp4"
        aria-label="Missing video"
      />
      <VideoPlayerControls>
        <VideoPlayerSeekBar />
        <VideoPlayerPlayButton />
        <VideoPlayerTime />
      </VideoPlayerControls>
    </VideoPlayer>
  )
}

Right to left

Labels follow the page language. The timeline and transport controls stay left to right, the way platform media players do.

0:00 / --:--
import {
  VideoPlayer,
  VideoPlayerContent,
  VideoPlayerControls,
  VideoPlayerFullscreenButton,
  VideoPlayerPlaybackRate,
  VideoPlayerPlayButton,
  VideoPlayerSeekBar,
  VideoPlayerSpacer,
  VideoPlayerTime,
  VideoPlayerVolume,
} from "@/components/ui/video-player"

export function VideoPlayerRtl() {
  return (
    <div dir="rtl" className="w-full max-w-xl">
      <VideoPlayer aria-label="مشغل الفيديو">
        <VideoPlayerContent
          src="https://media.w3.org/2010/05/sintel/trailer.mp4"
          poster="https://media.w3.org/2010/05/sintel/poster.png"
          aria-label="إعلان سينتل"
        />
        <VideoPlayerControls>
          <VideoPlayerSeekBar label="تقديم" />
          <VideoPlayerPlayButton
            playLabel="تشغيل"
            pauseLabel="إيقاف مؤقت"
            replayLabel="إعادة التشغيل"
          />
          <VideoPlayerVolume
            label="مستوى الصوت"
            muteLabel="كتم الصوت"
            unmuteLabel="إلغاء كتم الصوت"
          />
          <VideoPlayerTime />
          <VideoPlayerSpacer />
          <VideoPlayerPlaybackRate label="سرعة التشغيل" normalLabel="عادي" />
          <VideoPlayerFullscreenButton
            enterLabel="ملء الشاشة"
            exitLabel="الخروج من ملء الشاشة"
          />
        </VideoPlayerControls>
      </VideoPlayer>
    </div>
  )
}

These work while focus is anywhere inside the player, or on the page with globalShortcuts. They are skipped while a modifier key is held or focus is in a text field.

KeyAction
SpaceKPlays or pauses.
JGoes back 10 seconds.
LGoes forward 10 seconds.
←→Goes back or forward 5 seconds. On the seek bar, Shift jumps 10.
↑↓Turns the volume up or down by 5%.
MMutes or unmutes.
CTurns captions on or off, when the video has them.
FEnters or leaves full screen.
IOpens or closes picture in picture, where supported.
Shift+.Shift+,Speeds up or slows down playback.
0–9Jumps to 0% through 90% of the video.
HomeEndJumps to the start or the end.
  • The player is a labelled region. Every button has a name that follows its state (Play, Pause, Replay) and a tooltip with its shortcut.
  • The seek bar and volume are sliders. The seek bar reads its value as “1 minute 5 seconds of 3 minutes”.
  • Actions from shortcuts and video clicks are announced politely, for example “Paused” or “Volume 40%”. Load errors are announced as an alert.
  • In the overlay variant, controls fade out after 2.5 seconds of playback without pointer movement. They stay visible while paused, while you hover or use them with the keyboard, and while a menu is open.
  • On touch screens, a tap shows or hides the controls and a double tap on the left or right third goes back or forward 10 seconds. Keep tapping to add 10 seconds each time.
  • The captions button is a toggle with aria-pressed. It picks the last track you used, then one in the browser’s language, then the first one.
  • With reduced motion, controls and feedback fade without moving or scaling.

The seek bar and volume are built on the Base UI slider, and the buttons on HextaUI’s Button, Tooltip and Dropdown menu.

PropTypeDefault
variantoverlay floats auto-hiding controls over the video. bar puts them below it.
"overlay" | "bar""overlay"
shortcutsKeyboard shortcuts while focus is in the player.
booleantrue
globalShortcutsAlso listen for shortcuts on the whole page.
booleanfalse
errorMessage
ReactNode"This video can’t be played."
AttributeDescription
data-slot="video-player"Target the root in CSS.
data-variantThe current variant.
data-controls"visible" or "hidden". The cursor hides with the controls.
data-fullscreenPresent while the player is full screen.
aria-busySet while playback waits for data.

The <video> element. It takes every video attribute, and <source> or <track> children. A click plays or pauses, a double click toggles full screen, a tap shows or hides the controls and a double tap at either side seeks.

PropTypeDefault
autoPlayStarts playback on mount, except under reduced motion.
booleanfalse
playsInline
booleantrue
preload
"none" | "metadata" | "auto""metadata"
doubleTapSeekSeconds a double tap at either side jumps on touch screens. false turns it off.
number | false10
renderSwap in another media element, such as an HLS video element.
ReactElement | (props, state) => ReactElement<video>
AttributeDescription
data-slot="video-player-content"Target the video in CSS.
PropTypeDefault
tooltipsShow each control’s label and shortcut on hover.
booleantrue
AttributeDescription
data-slot="video-player-controls"Target the control bar in CSS.
data-hiddenPresent while overlay controls are hidden.

Always takes its own row above the buttons. Hover shows the time under the pointer, and the lighter track shows what has loaded.

PropTypeDefault
label
string"Seek"
onValueChange
(value: number, details) => void–
onValueCommitted
(value: number, details) => void–
disabled
booleanfalse
AttributeDescription
data-slot="video-player-seek-bar"Target the seek bar in CSS.
data-draggingPresent while you scrub.
data-previewingPresent on the control while the hover time is shown.
--video-player-bufferedThe loaded part of the video, from 0 to 1.
--video-player-hoverThe pointer position along the bar, from 0 to 1.
PropTypeDefault
playLabel
string"Play"
pauseLabel
string"Pause"
replayLabel
string"Replay"
...propsEvery Button prop, including variant and size.
ButtonProps–
AttributeDescription
data-slot="video-player-play-button"Target the button in CSS.
data-state"paused", "playing" or "ended".
PropTypeDefault
offsetSeconds to jump. Negative values go back.
number10
label
string"Forward 10 seconds"
...propsEvery Button prop, including variant and size.
ButtonProps–
AttributeDescription
data-slot="video-player-seek-button"Target the button in CSS.
data-direction"backward" or "forward".

A mute button with a slider that opens on hover or focus. On touch screens only the mute button shows, since phones control volume with their own buttons.

PropTypeDefault
label
string"Volume"
muteLabel
string"Mute"
unmuteLabel
string"Unmute"
AttributeDescription
data-slot="video-player-volume"Target the group in CSS.
data-slot="video-player-mute-button"The mute button. Also exported as VideoPlayerMuteButton.
data-stateOn the mute button: "muted", "low" or "high".
PropTypeDefault
type
"both" | "elapsed" | "remaining" | "duration""both"
AttributeDescription
data-slot="video-player-time"Target the time in CSS.
data-typeThe current type.
PropTypeDefault
rates
number[][0.5, 0.75, 1, 1.25, 1.5, 2]
label
string"Playback speed"
normalLabel
string"Normal"
AttributeDescription
data-slot="video-player-playback-rate"Target the menu trigger in CSS.

Makes the whole player full screen, or the video itself on iPhone. Renders nothing where full screen isn’t available.

PropTypeDefault
enterLabel
string"Full screen"
exitLabel
string"Exit full screen"
AttributeDescription
data-slot="video-player-fullscreen-button"Target the button in CSS.
data-state"on" or "off".

Renders nothing until the video has a subtitles or captions track.

PropTypeDefault
label
string"Captions"
AttributeDescription
data-slot="video-player-captions-button"Target the button in CSS.
data-state"on" or "off".
data-slot="video-player-captions"The caption text on the video. data-lifted is present while it sits above the controls.

Renders nothing in browsers without picture in picture.

PropTypeDefault
enterLabel
string"Picture in picture"
exitLabel
string"Exit picture in picture"
AttributeDescription
data-slot="video-player-pip-button"Target the button in CSS.
data-state"on" or "off".

Fills the free space in the control row, pushing the controls after it to the end.

Returns the player’s state and actions. Pass a selector that returns a single value.

const paused = useVideoPlayer((player) => player.paused)
const seek = useVideoPlayer((player) => player.seek)
PropTypeDefault
state
paused, ended, started, waiting, scrubbing, currentTime, duration, buffered, volume, muted, playbackRate, fullscreen, pictureInPicture, error, hasCaptions, captions, caption–
actions
play, pause, togglePaused, seek, seekBy, setVolume, toggleMuted, setPlaybackRate, toggleFullscreen, togglePictureInPicture, toggleCaptions–