Video Controls
A floating pill of controls over a video or animation player: play, a seek bar, the time and the speaker. It shows when the pointer moves and hides after two idle seconds.
Installation
$
npx shadcn@latest add https://ui.mattdowney.com/r/video-controls.jsonUsage
import { VideoControls } from "@/components/ui/video-controls"Examples
Default
Put the controls in the same positioned box as the video. A mouse click on the video plays or pauses it; on touch, the first tap only shows the controls.
0:000:00
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| media* | RefObject<HTMLVideoElement> | VideoControlsMedia | - | A ref to a <video>, or an adapter for any other player. The video must render together with the controls. |
| showSound | boolean | true | Shows the speaker. Pass false for a clip with no sound. |
| hotkeys | boolean | false | Space plays and pauses and M mutes, from anywhere on the page. Only for a player that owns the page, such as a lightbox. |
| onMutedChange | (muted: boolean) => void | - | Called after the speaker or M changes the sound, to remember the choice. |
media*RefObject<HTMLVideoElement> | VideoControlsMediaA ref to a <video>, or an adapter for any other player. The video must render together with the controls. |
showSoundboolean = trueShows the speaker. Pass false for a clip with no sound. |
| hotkeysboolean = falseSpace plays and pauses and M mutes, from anywhere on the page. Only for a player that owns the page, such as a lightbox. |
| onMutedChange(muted: boolean) => voidCalled after the speaker or M changes the sound, to remember the choice. |
"use client"
import * as React from "react"
import { Slider as SliderPrimitive } from "@base-ui/react/slider"
import {
IconPlayerPauseFilled,
IconPlayerPlayFilled,
IconVolume,
IconVolume3,
} from "@tabler/icons-react"
import { cn } from "@/registry/lib/utils"
/** What the controls listen for. A `<video>` fires all of these itself. */
type VideoControlsEvent = "play" | "pause" | "durationchange" | "timeupdate" | "volumechange"
/**
* The player the controls drive, in seconds. Pass a `<video>` ref and the
* controls build this for you; write one for any other player (a Remotion
* `<Player>`, an embed with its own API).
*
* A player that loops a picked range reports `currentTime` and `duration`
* within that range, and `seek` takes a time within it, so the bar spans the
* part that plays.
*/
type VideoControlsMedia = {
play: () => void | Promise<void>
pause: () => void
seek: (time: number) => void
currentTime: () => number
duration: () => number
paused: () => boolean
muted: () => boolean
setMuted: (muted: boolean) => void
/** Calls `listener` on `event` and returns the function that stops it. */
subscribe: (event: VideoControlsEvent, listener: () => void) => () => void
}
const IDLE_MS = 2000
function formatTime(s: number): string {
if (!Number.isFinite(s) || s < 0) s = 0
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60)
return `${m}:${sec.toString().padStart(2, "0")}`
}
function elementMedia(v: HTMLVideoElement): VideoControlsMedia {
return {
// A refused play (autoplay rules, low power mode) rejects; the pause event
// never fires, so the button already shows Play and nothing else is owed.
play: () => v.play().catch(() => {}),
pause: () => v.pause(),
seek: (time) => {
v.currentTime = time
},
currentTime: () => v.currentTime,
duration: () => (Number.isFinite(v.duration) ? v.duration : 0),
paused: () => v.paused,
muted: () => v.muted,
setMuted: (muted) => {
v.muted = muted
},
subscribe: (event, listener) => {
// Duration first arrives with the metadata, not as a change.
const names = event === "durationchange" ? ["loadedmetadata", "durationchange"] : [event]
for (const name of names) v.addEventListener(name, listener)
return () => {
for (const name of names) v.removeEventListener(name, listener)
}
},
}
}
function isElementRef(
media: VideoControlsMedia | React.RefObject<HTMLVideoElement | null>
): media is React.RefObject<HTMLVideoElement | null> {
return "current" in media
}
// Kit `icon` Button geometry (32px box, 14px glyph, the action stroke, the
// pressed scale) with two media exceptions: round, so it nests concentric in
// the pill (pill radius 20 - 4px inset = 16, half of 32), and white with a
// white focus ring, because the theme's ring is dark in light mode and would
// vanish on the scrim.
const controlButton =
"tap-target flex size-8 shrink-0 items-center justify-center rounded-full outline-none transition-[transform,background-color] duration-small ease-small hover:bg-white/15 active:scale-[0.98] motion-reduce:transition-[background-color] motion-reduce:active:scale-100 focus-visible:ring-[3px] focus-visible:ring-white/60 [&_svg]:pointer-events-none [&_svg]:size-3.5 [&_svg]:[stroke-width:var(--stroke-icon-action)]"
// @use-when your own controls over a video or animation player: a floating
// pill with play, a seek bar, the time and the speaker, shown on pointer move
// and hidden when idle. Put it inside the box that holds the player.
function VideoControls({
media,
showSound = true,
hotkeys = false,
onMutedChange,
className,
}: {
/** A ref to a `<video>`, or an adapter for any other player. */
media: VideoControlsMedia | React.RefObject<HTMLVideoElement | null>
/** Show the speaker. Pass `false` for a clip with no sound. */
showSound?: boolean
/** Space plays and pauses and M mutes, from anywhere on the page. Only for
* a player that owns the page, such as a lightbox; two players with it on
* would both answer. */
hotkeys?: boolean
/** Called after the speaker or M changes the sound, to remember the choice. */
onMutedChange?: (muted: boolean) => void
className?: string
}) {
const pillRef = React.useRef<HTMLDivElement>(null)
const mediaRef = React.useRef<VideoControlsMedia | null>(null)
// Follows the player's own play and pause events, so a refused autoplay
// never shows a Pause button on a stopped video.
const [playing, setPlaying] = React.useState(false)
const [time, setTime] = React.useState(0)
const [duration, setDuration] = React.useState(0)
const [muted, setMuted] = React.useState(false)
const [awake, setAwake] = React.useState(true)
const [focused, setFocused] = React.useState(false)
const [scrubbing, setScrubbing] = React.useState(false)
const scrubbingRef = React.useRef(false)
const idleRef = React.useRef<number | null>(null)
// Any sign of life shows the pill and restarts the idle countdown.
const poke = React.useCallback(() => {
setAwake(true)
if (idleRef.current) window.clearTimeout(idleRef.current)
idleRef.current = window.setTimeout(() => setAwake(false), IDLE_MS)
}, [])
const togglePlay = React.useCallback(() => {
const m = mediaRef.current
if (!m) return
if (m.paused()) m.play()
else m.pause()
poke()
}, [poke])
const toggleMuted = React.useCallback(() => {
const m = mediaRef.current
if (!m) return
const next = !m.muted()
m.setMuted(next)
onMutedChange?.(next)
poke()
}, [onMutedChange, poke])
// Mirror the player. A time event fires only a few times a second, which
// steps the bar visibly, so while playing the time is read every frame.
React.useEffect(() => {
const m = isElementRef(media) ? (media.current ? elementMedia(media.current) : null) : media
mediaRef.current = m
if (!m) return
let frame = 0
const tick = () => {
if (!scrubbingRef.current) setTime(m.currentTime())
frame = requestAnimationFrame(tick)
}
const onPlay = () => {
setPlaying(true)
cancelAnimationFrame(frame)
frame = requestAnimationFrame(tick)
}
const onPause = () => {
setPlaying(false)
cancelAnimationFrame(frame)
setTime(m.currentTime())
}
const onTime = () => {
if (m.paused() && !scrubbingRef.current) setTime(m.currentTime())
}
const onDuration = () => setDuration(m.duration())
const onVolume = () => setMuted(m.muted())
const stops = [
m.subscribe("play", onPlay),
m.subscribe("pause", onPause),
m.subscribe("timeupdate", onTime),
m.subscribe("durationchange", onDuration),
m.subscribe("volumechange", onVolume),
]
// The player may already be running (an autoplay that started first).
onDuration()
onVolume()
if (m.paused()) onPause()
else onPlay()
idleRef.current = window.setTimeout(() => setAwake(false), IDLE_MS)
return () => {
cancelAnimationFrame(frame)
for (const stop of stops) stop()
if (idleRef.current) window.clearTimeout(idleRef.current)
}
}, [media])
// The box around the player is the surface: moving over it wakes the pill,
// and a mouse click plays or pauses. A tap on touch only wakes the pill, so
// the first tap shows the controls instead of stopping the video.
React.useEffect(() => {
const surface = pillRef.current?.parentElement
if (!surface) return
let pointerType = "mouse"
const onDown = (e: PointerEvent) => {
pointerType = e.pointerType
poke()
}
const onClick = (e: MouseEvent) => {
const target = e.target as HTMLElement | null
if (pillRef.current?.contains(target)) return
if (target?.closest("button, a, input, textarea, select, [role=slider]")) return
if (pointerType === "touch") return
togglePlay()
}
surface.addEventListener("pointermove", poke)
surface.addEventListener("pointerdown", onDown)
surface.addEventListener("click", onClick)
return () => {
surface.removeEventListener("pointermove", poke)
surface.removeEventListener("pointerdown", onDown)
surface.removeEventListener("click", onClick)
}
}, [poke, togglePlay])
// Skipped when focus is on a control that owns the key itself (a button
// activates on Space; the seek bar takes arrows).
React.useEffect(() => {
if (!hotkeys) return
const onKey = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey) return
const target = e.target as HTMLElement | null
if (target?.closest("button, input, textarea, select, [role=slider], [contenteditable]")) return
if (e.key === " ") {
e.preventDefault()
togglePlay()
} else if ((e.key === "m" || e.key === "M") && showSound) {
toggleMuted()
}
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [hotkeys, showSound, togglePlay, toggleMuted])
const visible = awake || !playing || scrubbing || focused
return (
<div
ref={pillRef}
data-slot="video-controls"
data-visible={visible || undefined}
onPointerMove={poke}
onFocus={() => setFocused(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false)
}}
className={cn(
// p-1 keeps the round buttons concentric with the pill. White on a
// dark scrim in both themes, the way a media scrim never follows the
// theme. The blurred fill goes opaque under reduced transparency.
"absolute inset-x-3 bottom-3 mx-auto flex max-w-md items-center gap-2 rounded-full bg-black/55 p-1 text-white backdrop-blur-md transition-[opacity,translate] duration-large ease-large motion-reduce:transition-[opacity] reduce-transparency:bg-black reduce-transparency:backdrop-blur-none",
visible ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-1 opacity-0 motion-reduce:translate-y-0",
// With no speaker the pill ends on the time, not a round button, so
// match the left glyph's inset (4px pad + 9px inside the button).
!showSound && "pr-3",
className
)}
>
<button type="button" aria-label={playing ? "Pause" : "Play"} onClick={togglePlay} className={controlButton}>
{playing ? <IconPlayerPauseFilled /> : <IconPlayerPlayFilled />}
</button>
<span className="shrink-0 text-xs tabular-nums">{formatTime(time)}</span>
{/* Base UI's slider, not the kit Slider: that track is coloured for a
page surface and disappears on a dark scrim. */}
<SliderPrimitive.Root
aria-label="Seek"
value={time}
min={0}
max={duration || 1}
step={0.01}
disabled={!duration}
thumbAlignment="edge"
onPointerDown={() => {
scrubbingRef.current = true
setScrubbing(true)
}}
onValueChange={(v) => {
const next = Array.isArray(v) ? v[0] : v
setTime(next)
mediaRef.current?.seek(next)
poke()
}}
onValueCommitted={() => {
scrubbingRef.current = false
setScrubbing(false)
}}
className="group relative flex h-8 min-w-0 flex-1 touch-none items-center select-none data-[disabled]:opacity-50"
>
<SliderPrimitive.Control className="relative flex h-full w-full items-center">
<SliderPrimitive.Track className="relative h-1 w-full rounded-full bg-white/25">
<SliderPrimitive.Indicator className="absolute h-full rounded-full bg-white" />
<SliderPrimitive.Thumb
getAriaLabel={() => "Seek"}
aria-valuetext={`${formatTime(time)} of ${formatTime(duration)}`}
className="tap-target block size-3 cursor-grab rounded-full bg-white shadow-sm outline-none transition-[scale] duration-small ease-small group-hover:scale-110 focus-visible:ring-[3px] focus-visible:ring-white/60 active:cursor-grabbing motion-reduce:transition-none"
/>
</SliderPrimitive.Track>
</SliderPrimitive.Control>
</SliderPrimitive.Root>
<span className="shrink-0 text-xs tabular-nums text-white/70">{formatTime(duration)}</span>
{showSound && (
<button
type="button"
aria-label={muted ? "Turn sound on" : "Turn sound off"}
aria-pressed={!muted}
onClick={toggleMuted}
className={controlButton}
>
{muted ? <IconVolume3 /> : <IconVolume />}
</button>
)}
</div>
)
}
export { VideoControls, type VideoControlsMedia, type VideoControlsEvent }