Video player
A video player with a scrubbable seek bar, auto-hiding controls, keyboard shortcuts, speed, picture in picture and full screen.
pnpm dlx shadcn@latest add https://hextaui.com/r/video-player.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
Add the theme tokens to your global CSS, if you haven’t yet.
Install the dependencies.
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cnCopy and paste the following code into your project.
components/ui/video-player.tsx components/ui/button.tsx components/ui/dropdown-menu.tsx components/ui/kbd.tsx components/ui/spinner.tsx components/ui/tooltip.tsx Update the import paths to match your project setup.
Bar
variant="bar" puts the controls under the picture on the page surface. They never hide or cover the video.
Minimal
Use only the parts you need. tooltips={false} turns off the hover hints, and type="remaining" counts down instead of up.
Seek offsets and speeds
offset sets how far each seek button jumps, and rates sets the speeds in the menu.
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.
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.
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.
Error
When the source fails, the player shows errorMessage, announces it and disables the controls that can’t work.
Right to left
Labels follow the page language. The timeline and transport controls stay left to right, the way platform media players do.
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.
| Key | Action |
|---|---|
| SpaceK | Plays or pauses. |
| J | Goes back 10 seconds. |
| L | Goes forward 10 seconds. |
| ←→ | Goes back or forward 5 seconds. On the seek bar, Shift jumps 10. |
| ↑↓ | Turns the volume up or down by 5%. |
| M | Mutes or unmutes. |
| C | Turns captions on or off, when the video has them. |
| F | Enters or leaves full screen. |
| I | Opens or closes picture in picture, where supported. |
| Shift+.Shift+, | Speeds up or slows down playback. |
| 0–9 | Jumps to 0% through 90% of the video. |
| HomeEnd | Jumps 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.
| Prop | Type | Default |
|---|---|---|
variantoverlay floats auto-hiding controls over the video. bar puts them below it. | "overlay" | "bar" | "overlay" |
shortcutsKeyboard shortcuts while focus is in the player. | boolean | true |
globalShortcutsAlso listen for shortcuts on the whole page. | boolean | false |
errorMessage | ReactNode | "This video can’t be played." |
| Attribute | Description |
|---|---|
data-slot="video-player" | Target the root in CSS. |
data-variant | The current variant. |
data-controls | "visible" or "hidden". The cursor hides with the controls. |
data-fullscreen | Present while the player is full screen. |
aria-busy | Set 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.
| Prop | Type | Default |
|---|---|---|
autoPlayStarts playback on mount, except under reduced motion. | boolean | false |
playsInline | boolean | true |
preload | "none" | "metadata" | "auto" | "metadata" |
doubleTapSeekSeconds a double tap at either side jumps on touch screens. false turns it off. | number | false | 10 |
renderSwap in another media element, such as an HLS video element. | ReactElement | (props, state) => ReactElement | <video> |
| Attribute | Description |
|---|---|
data-slot="video-player-content" | Target the video in CSS. |
| Prop | Type | Default |
|---|---|---|
tooltipsShow each control’s label and shortcut on hover. | boolean | true |
| Attribute | Description |
|---|---|
data-slot="video-player-controls" | Target the control bar in CSS. |
data-hidden | Present 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.
| Prop | Type | Default |
|---|---|---|
label | string | "Seek" |
onValueChange | (value: number, details) => void | – |
onValueCommitted | (value: number, details) => void | – |
disabled | boolean | false |
| Attribute | Description |
|---|---|
data-slot="video-player-seek-bar" | Target the seek bar in CSS. |
data-dragging | Present while you scrub. |
data-previewing | Present on the control while the hover time is shown. |
--video-player-buffered | The loaded part of the video, from 0 to 1. |
--video-player-hover | The pointer position along the bar, from 0 to 1. |
| Prop | Type | Default |
|---|---|---|
playLabel | string | "Play" |
pauseLabel | string | "Pause" |
replayLabel | string | "Replay" |
...propsEvery Button prop, including variant and size. | ButtonProps | – |
| Attribute | Description |
|---|---|
data-slot="video-player-play-button" | Target the button in CSS. |
data-state | "paused", "playing" or "ended". |
| Prop | Type | Default |
|---|---|---|
offsetSeconds to jump. Negative values go back. | number | 10 |
label | string | "Forward 10 seconds" |
...propsEvery Button prop, including variant and size. | ButtonProps | – |
| Attribute | Description |
|---|---|
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.
| Prop | Type | Default |
|---|---|---|
label | string | "Volume" |
muteLabel | string | "Mute" |
unmuteLabel | string | "Unmute" |
| Attribute | Description |
|---|---|
data-slot="video-player-volume" | Target the group in CSS. |
data-slot="video-player-mute-button" | The mute button. Also exported as VideoPlayerMuteButton. |
data-state | On the mute button: "muted", "low" or "high". |
| Prop | Type | Default |
|---|---|---|
type | "both" | "elapsed" | "remaining" | "duration" | "both" |
| Attribute | Description |
|---|---|
data-slot="video-player-time" | Target the time in CSS. |
data-type | The current type. |
| Prop | Type | Default |
|---|---|---|
rates | number[] | [0.5, 0.75, 1, 1.25, 1.5, 2] |
label | string | "Playback speed" |
normalLabel | string | "Normal" |
| Attribute | Description |
|---|---|
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.
| Prop | Type | Default |
|---|---|---|
enterLabel | string | "Full screen" |
exitLabel | string | "Exit full screen" |
| Attribute | Description |
|---|---|
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.
| Prop | Type | Default |
|---|---|---|
label | string | "Captions" |
| Attribute | Description |
|---|---|
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.
| Prop | Type | Default |
|---|---|---|
enterLabel | string | "Picture in picture" |
exitLabel | string | "Exit picture in picture" |
| Attribute | Description |
|---|---|
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.
| Prop | Type | Default |
|---|---|---|
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 | – |
- ButtonButtons in every variant and size, with a built-in loading, success and error flow that skips the spinner for fast requests.
- Dropdown menuA menu of actions and options behind a button, with groups, submenus, checkbox and radio items, and shortcuts.
- KbdKey caps for shortcuts that show the right symbols on every platform, read them out properly and press down with the real keys.
- MotionThe easing curves, durations and reduced-motion check every component animates with, plus hooks for size morphs and sliding highlights.
- SpinnerA loading indicator with Apple-style ticks or a breathing ring that can wait before showing and stay long enough not to flicker.
- TooltipA short hint on hover or keyboard focus that opens after a brief rest, switches instantly between neighbours and shows shortcuts.