A video player with a scrubbable seek bar, auto-hiding controls, keyboard shortcuts, speed, picture in picture and full screen.
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.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
- 1
Add the theme tokens to your global CSS, if you haven’t yet.
- 2
Install the dependencies.
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cn - 3
Copy and paste the following code into your project.
components/ui/video-player.tsx "use client" import * as React from "react" import { mergeProps } from "@base-ui/react/merge-props" import { Slider as SliderPrimitive } from "@base-ui/react/slider" import { useRender } from "@base-ui/react/use-render" import { IconAlertTriangle, IconBadgeCc, IconBadgeCcFilled, IconChevronsLeft, IconChevronsRight, IconMaximize, IconMinimize, IconPictureInPictureOff, IconPictureInPictureOn, IconPlayerPauseFilled, IconPlayerPlayFilled, IconRewindBackward10, IconRewindBackward15, IconRewindBackward30, IconRewindBackward5, IconRewindForward10, IconRewindForward15, IconRewindForward30, IconRewindForward5, IconRotateClockwise, IconVolume, IconVolume2, IconVolume3, } from "@tabler/icons-react" import { cva } from "class-variance-authority" import { cn } from "cn" import { prefersReducedMotion } from "@/lib/motion" import { Button } from "@/components/ui/button" import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Kbd } from "@/components/ui/kbd" import { Spinner } from "@/components/ui/spinner" import { TooltipGroup, TooltipTrigger } from "@/components/ui/tooltip" import { useMergedRef } from "@/hooks/use-merged-ref" type VideoPlayerVariant = "overlay" | "bar" type VideoPlayerMediaState = { paused: boolean ended: boolean started: boolean waiting: boolean scrubbing: boolean scrubTime: number currentTime: number duration: number buffered: number volume: number muted: boolean playbackRate: number fullscreen: boolean pictureInPicture: boolean error: boolean hasCaptions: boolean captions: boolean caption: string } type VideoPlayerActions = { play: () => void pause: () => void togglePaused: () => void seek: (time: number) => void seekBy: (offset: number) => void setVolume: (volume: number) => void toggleMuted: () => void setPlaybackRate: (rate: number) => void toggleFullscreen: () => void togglePictureInPicture: () => void toggleCaptions: () => void } type VideoPlayerApi = VideoPlayerMediaState & VideoPlayerActions type VideoPlayerStore = { get: () => VideoPlayerApi getInitial: () => VideoPlayerApi set: (patch: Partial<VideoPlayerMediaState>) => void subscribe: (listener: () => void) => () => void syncTracks: () => void } type FlashKind = | "play" | "pause" | "back" | "forward" | "volume" | "mute" | "rate" | "captions" type FlashSide = "start" | "center" | "end" type Flash = { id: number; kind: FlashKind; text?: string; side: FlashSide } type Notify = ( kind: FlashKind, text?: string, spoken?: string, side?: FlashSide ) => void type VideoPlayerContextValue = { store: VideoPlayerStore variant: VideoPlayerVariant rootRef: React.RefObject<HTMLDivElement | null> videoRef: React.RefObject<HTMLVideoElement | null> controlsHidden: boolean hold: (reason: string, active: boolean) => void reveal: () => void hideControls: () => void notify: Notify shortcuts: boolean } const VideoPlayerContext = React.createContext<VideoPlayerContextValue | null>( null ) const VideoPlayerControlsContext = React.createContext<{ tooltips: boolean } | null>(null) function useVideoPlayerContext(part: string) { const context = React.useContext(VideoPlayerContext) if (!context) { throw new Error(`${part} must be used within <VideoPlayer>.`) } return context } const initialState: VideoPlayerMediaState = { paused: true, ended: false, started: false, waiting: false, scrubbing: false, scrubTime: 0, currentTime: 0, duration: 0, buffered: 0, volume: 1, muted: false, playbackRate: 1, fullscreen: false, pictureInPicture: false, error: false, hasCaptions: false, captions: false, caption: "", } const defaultRates = [0.5, 0.75, 1, 1.25, 1.5, 2] const hideDelay = 2500 function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)) } function isSeekable(duration: number) { return Number.isFinite(duration) && duration > 0 } function formatTime(seconds: number, reference = seconds) { const total = Number.isFinite(seconds) && seconds > 0 ? Math.floor(seconds) : 0 const hours = Math.floor(total / 3600) const minutes = Math.floor((total % 3600) / 60) const rest = String(total % 60).padStart(2, "0") if (hours > 0 || (Number.isFinite(reference) && reference >= 3600)) { return `${hours}:${String(minutes).padStart(2, "0")}:${rest}` } return `${minutes}:${rest}` } function spokenTime(seconds: number) { const total = Number.isFinite(seconds) && seconds > 0 ? Math.floor(seconds) : 0 const hours = Math.floor(total / 3600) const minutes = Math.floor((total % 3600) / 60) const rest = total % 60 const parts: string[] = [] if (hours) { parts.push(`${hours} ${hours === 1 ? "hour" : "hours"}`) } if (minutes) { parts.push(`${minutes} ${minutes === 1 ? "minute" : "minutes"}`) } if (rest || parts.length === 0) { parts.push(`${rest} ${rest === 1 ? "second" : "seconds"}`) } return parts.join(" ") } function formatRate(rate: number) { return `${Number(rate.toFixed(2))}×` } function readBuffered(video: HTMLVideoElement) { const { buffered, currentTime, duration } = video if (!isSeekable(duration)) { return 0 } for (let index = 0; index < buffered.length; index++) { if ( buffered.start(index) <= currentTime + 0.5 && buffered.end(index) >= currentTime ) { return clamp(buffered.end(index) / duration, 0, 1) } } return 0 } type FullscreenDocument = Document & { webkitFullscreenElement?: Element | null webkitFullscreenEnabled?: boolean webkitExitFullscreen?: () => Promise<void> | void } type FullscreenElement = HTMLElement & { webkitRequestFullscreen?: () => Promise<void> | void } type FullscreenVideo = HTMLVideoElement & { webkitEnterFullscreen?: () => void webkitExitFullscreen?: () => void webkitDisplayingFullscreen?: boolean } function fullscreenElement() { const doc = document as FullscreenDocument return doc.fullscreenElement ?? doc.webkitFullscreenElement ?? null } function canFullscreen() { const doc = document as FullscreenDocument return Boolean( doc.fullscreenEnabled || doc.webkitFullscreenEnabled || (typeof HTMLVideoElement !== "undefined" && "webkitEnterFullscreen" in HTMLVideoElement.prototype) ) } function canPictureInPicture() { return Boolean( (document as Document & { pictureInPictureEnabled?: boolean }) .pictureInPictureEnabled ) } function captionTracks(video: HTMLVideoElement) { const tracks: TextTrack[] = [] const list = video.textTracks if (!list) { return tracks } for (let index = 0; index < list.length; index++) { const track = list[index] if (track.kind === "subtitles" || track.kind === "captions") { tracks.push(track) } } return tracks } function subscribeNever() { return () => {} } function useSupport(check: () => boolean) { return React.useSyncExternalStore(subscribeNever, check, () => false) } function ignoreRejection(result: unknown) { if (result && typeof (result as Promise<unknown>).catch === "function") { ;(result as Promise<unknown>).catch(() => {}) } } function createVideoPlayerStore( videoRef: React.RefObject<HTMLVideoElement | null>, rootRef: React.RefObject<HTMLDivElement | null> ): VideoPlayerStore { const listeners = new Set<() => void>() let snapshot: VideoPlayerApi const set = (patch: Partial<VideoPlayerMediaState>) => { let changed = false for (const key in patch) { const name = key as keyof VideoPlayerMediaState if (!Object.is(snapshot[name], patch[name])) { changed = true break } } if (!changed) { return } snapshot = { ...snapshot, ...patch } listeners.forEach((listener) => listener()) } const actions: VideoPlayerActions = { play: () => { const video = videoRef.current if (video) { ignoreRejection(video.play()) } }, pause: () => { videoRef.current?.pause() }, togglePaused: () => { const video = videoRef.current if (!video) { return } if (video.paused || video.ended) { ignoreRejection(video.play()) } else { video.pause() } }, seek: (time) => { const video = videoRef.current if (!video || !isSeekable(video.duration)) { return } const next = clamp(time, 0, video.duration) video.currentTime = next set({ currentTime: next, ended: false }) }, seekBy: (offset) => { const video = videoRef.current if (video) { actions.seek(video.currentTime + offset) } }, setVolume: (volume) => { const video = videoRef.current if (!video) { return } const next = clamp(volume, 0, 1) video.volume = next video.muted = next === 0 }, toggleMuted: () => { const video = videoRef.current if (!video) { return } if (video.muted || video.volume === 0) { if (video.volume === 0) { video.volume = 0.5 } video.muted = false } else { video.muted = true } }, setPlaybackRate: (rate) => { const video = videoRef.current if (video && Number.isFinite(rate) && rate > 0) { video.playbackRate = clamp(rate, 0.0625, 16) } }, toggleFullscreen: () => { const root = rootRef.current as FullscreenElement | null const video = videoRef.current as FullscreenVideo | null const doc = document as FullscreenDocument if (fullscreenElement()) { ignoreRejection( doc.exitFullscreen ? doc.exitFullscreen() : doc.webkitExitFullscreen?.() ) return } if (video?.webkitDisplayingFullscreen) { video.webkitExitFullscreen?.() return } if (root && doc.fullscreenEnabled && root.requestFullscreen) { ignoreRejection(root.requestFullscreen()) } else if (root?.webkitRequestFullscreen && doc.webkitFullscreenEnabled) { ignoreRejection(root.webkitRequestFullscreen()) } else { video?.webkitEnterFullscreen?.() } }, toggleCaptions: () => {}, togglePictureInPicture: () => { const video = videoRef.current if (!video) { return } const doc = document as Document & { pictureInPictureElement?: Element | null exitPictureInPicture?: () => Promise<void> } if (doc.pictureInPictureElement) { ignoreRejection(doc.exitPictureInPicture?.()) } else if (canPictureInPicture() && !video.disablePictureInPicture) { ignoreRejection( ( video as HTMLVideoElement & { requestPictureInPicture?: () => Promise<unknown> } ).requestPictureInPicture?.() ) } }, } let activeTrack: TextTrack | null = null let lastTrack: TextTrack | null = null const readCaption = () => { const cues = activeTrack?.activeCues if (!cues) { return "" } const lines: string[] = [] for (let index = 0; index < cues.length; index++) { const text = (cues[index] as VTTCue).text if (typeof text === "string") { lines.push(text.replace(/<[^>]*>/g, "").trim()) } } return lines.filter(Boolean).join("\n") } const handleCueChange = () => set({ caption: readCaption() }) const activate = (track: TextTrack | null) => { if (track === activeTrack) { return } if (activeTrack) { activeTrack.removeEventListener("cuechange", handleCueChange) if (activeTrack.mode !== "disabled") { activeTrack.mode = "disabled" } } activeTrack = track if (track) { lastTrack = track track.mode = "hidden" track.addEventListener("cuechange", handleCueChange) } set({ captions: track !== null, caption: readCaption() }) } const syncTracks = () => { const video = videoRef.current if (!video) { return } const tracks = captionTracks(video) if (activeTrack && !tracks.includes(activeTrack)) { activeTrack.removeEventListener("cuechange", handleCueChange) activeTrack = null } if (lastTrack && !tracks.includes(lastTrack)) { lastTrack = null } const shown = tracks.find( (track) => track.mode === "showing" && track !== activeTrack ) if (shown) { activate(shown) } else if (activeTrack && activeTrack.mode === "disabled") { activate(null) } set({ hasCaptions: tracks.length > 0, captions: activeTrack !== null, caption: readCaption(), }) } actions.toggleCaptions = () => { const video = videoRef.current if (!video) { return } if (activeTrack) { activate(null) return } const tracks = captionTracks(video) const language = typeof navigator === "undefined" ? "" : navigator.language.split("-")[0] activate( lastTrack ?? tracks.find((track) => track.language.split("-")[0] === language) ?? tracks[0] ?? null ) } snapshot = { ...initialState, ...actions } const initial = snapshot return { get: () => snapshot, getInitial: () => initial, set, syncTracks, subscribe: (listener) => { listeners.add(listener) return () => { listeners.delete(listener) } }, } } function useStoreValue<T>( store: VideoPlayerStore, selector: (api: VideoPlayerApi) => T ) { return React.useSyncExternalStore( store.subscribe, () => selector(store.get()), () => selector(store.getInitial()) ) } const identity = (api: VideoPlayerApi) => api function useVideoPlayer(): VideoPlayerApi function useVideoPlayer<T>(selector: (api: VideoPlayerApi) => T): T function useVideoPlayer<T>(selector?: (api: VideoPlayerApi) => T) { const { store } = useVideoPlayerContext("useVideoPlayer") return useStoreValue( store, (selector ?? identity) as (api: VideoPlayerApi) => T | VideoPlayerApi ) } function isEditable(target: EventTarget | null) { if (!(target instanceof HTMLElement)) { return false } if (target.isContentEditable) { return true } if (target instanceof HTMLInputElement) { return ( target.type !== "range" && target.type !== "checkbox" && target.type !== "radio" && target.type !== "button" ) } return ( target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement ) } function isActivatable(target: EventTarget | null) { return ( target instanceof Element && target.closest( "button, a[href], summary, select, [role=button], [role=link], [role=checkbox], [role=switch], [role=tab], [role=option]" ) !== null ) } function isRange(target: EventTarget | null) { return ( target instanceof Element && (target.closest("[role=slider]") !== null || (target instanceof HTMLInputElement && target.type === "range")) ) } function inPopup(target: EventTarget | null) { return ( target instanceof Element && target.closest( "[role=menu], [role=listbox], [role=dialog], [role=alertdialog]" ) !== null ) } function runShortcut( event: KeyboardEvent | React.KeyboardEvent, api: VideoPlayerApi, notify: Notify ) { if ( event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey || isEditable(event.target) || inPopup(event.target) ) { return false } const key = event.key.length === 1 ? event.key.toLowerCase() : event.key const seekable = isSeekable(api.duration) const range = isRange(event.target) if (key === " " || key === "k") { if (key === " " && isActivatable(event.target)) { return false } if (event.shiftKey) { return false } const willPlay = api.paused || api.ended api.togglePaused() notify(willPlay ? "play" : "pause", willPlay ? "Playing" : "Paused") return true } if ( event.shiftKey && (key === ">" || key === "<" || key === "." || key === ",") ) { const faster = key === ">" || key === "." const index = defaultRates.findIndex( (rate) => rate >= api.playbackRate - 0.001 ) const current = index === -1 ? defaultRates.length - 1 : index const next = defaultRates[ clamp( defaultRates[current] === api.playbackRate || !faster ? current + (faster ? 1 : -1) : current, 0, defaultRates.length - 1 ) ] api.setPlaybackRate(next) notify("rate", formatRate(next)) return true } if (event.shiftKey) { return false } if (key === "j" || key === "l") { if (!seekable) { return false } api.seekBy(key === "j" ? -10 : 10) notify( key === "j" ? "back" : "forward", key === "j" ? "−10s" : "+10s", key === "j" ? "Back 10 seconds" : "Forward 10 seconds" ) return true } if (!range && (key === "ArrowLeft" || key === "ArrowRight")) { if (!seekable) { return false } api.seekBy(key === "ArrowLeft" ? -5 : 5) notify( key === "ArrowLeft" ? "back" : "forward", key === "ArrowLeft" ? "−5s" : "+5s", key === "ArrowLeft" ? "Back 5 seconds" : "Forward 5 seconds" ) return true } if (!range && (key === "ArrowUp" || key === "ArrowDown")) { const base = api.muted ? 0 : api.volume const next = clamp( Math.round((base + (key === "ArrowUp" ? 0.05 : -0.05)) * 100) / 100, 0, 1 ) api.setVolume(next) notify( next === 0 ? "mute" : "volume", `${Math.round(next * 100)}%`, `Volume ${Math.round(next * 100)}%` ) return true } if (key === "m") { const willMute = !(api.muted || api.volume === 0) api.toggleMuted() notify(willMute ? "mute" : "volume", willMute ? "Muted" : "Unmuted") return true } if (key === "c" && api.hasCaptions) { const willShow = !api.captions api.toggleCaptions() notify( "captions", willShow ? "On" : "Off", willShow ? "Captions on" : "Captions off" ) return true } if (key === "f") { api.toggleFullscreen() return true } if (key === "i" && canPictureInPicture()) { api.togglePictureInPicture() return true } if (!range && (key === "Home" || key === "End")) { if (!seekable) { return false } api.seek(key === "Home" ? 0 : api.duration) return true } if (/^[0-9]$/.test(key) && !range) { if (!seekable) { return false } api.seek((Number(key) / 10) * api.duration) return true } return false } const videoPlayerVariants = cva( "group/video-player @container/video-player relative isolate grid w-full grid-cols-[minmax(0,1fr)] overflow-hidden rounded-xl text-foreground outline-none select-none focus-visible:outline-hidden data-fullscreen:size-full data-fullscreen:rounded-none [&>*]:col-start-1", { variants: { variant: { overlay: "dark grid-rows-[minmax(0,1fr)] bg-background data-[controls=hidden]:cursor-none", bar: "grid-rows-[minmax(0,1fr)_auto] bg-card text-card-foreground ring-(length:--hairline) ring-foreground/10 data-fullscreen:ring-0 forced-colors:border", }, }, defaultVariants: { variant: "overlay", }, } ) type VideoPlayerProps = React.ComponentProps<"div"> & { variant?: VideoPlayerVariant shortcuts?: boolean globalShortcuts?: boolean errorMessage?: React.ReactNode } function VideoPlayer({ className, variant = "overlay", shortcuts = true, globalShortcuts = false, errorMessage = "This video can’t be played.", children, onKeyDown, onPointerMove, onPointerLeave, ref, ...props }: VideoPlayerProps) { const rootRef = React.useRef<HTMLDivElement | null>(null) const videoRef = React.useRef<HTMLVideoElement | null>(null) const [store] = React.useState(() => createVideoPlayerStore(videoRef, rootRef) ) const [controlsHidden, setControlsHidden] = React.useState(false) const [flash, setFlash] = React.useState<Flash | null>(null) const [announcement, setAnnouncement] = React.useState("") const holdsRef = React.useRef(new Set<string>()) const hideTimerRef = React.useRef<ReturnType<typeof setTimeout> | undefined>( undefined ) const flashTimerRef = React.useRef<ReturnType<typeof setTimeout> | undefined>( undefined ) const fullscreen = useStoreValue(store, (api) => api.fullscreen) const started = useStoreValue(store, (api) => api.started) const waiting = useStoreValue(store, (api) => api.waiting && !api.paused) const error = useStoreValue(store, (api) => api.error) const captions = useStoreValue(store, (api) => api.captions) const caption = useStoreValue(store, (api) => api.caption) const canHide = React.useCallback(() => { const api = store.get() return ( variant === "overlay" && !api.paused && !api.ended && !api.scrubbing && !api.error && holdsRef.current.size === 0 ) }, [store, variant]) const schedule = React.useCallback( (delay = hideDelay) => { clearTimeout(hideTimerRef.current) hideTimerRef.current = setTimeout(() => { if (canHide()) { setControlsHidden(true) } }, delay) }, [canHide] ) const reveal = React.useCallback(() => { setControlsHidden(false) schedule() }, [schedule]) const hideControls = React.useCallback(() => { clearTimeout(hideTimerRef.current) if (canHide()) { setControlsHidden(true) } }, [canHide]) const hold = React.useCallback( (reason: string, active: boolean) => { const holds = holdsRef.current if (active) { holds.add(reason) clearTimeout(hideTimerRef.current) setControlsHidden(false) } else if (holds.delete(reason)) { schedule() } }, [schedule] ) const notify = React.useCallback<Notify>( (kind, text, spoken = text, side = "center") => { clearTimeout(flashTimerRef.current) setFlash((previous) => ({ id: (previous?.id ?? 0) + 1, kind, text, side, })) setAnnouncement(spoken ?? "") flashTimerRef.current = setTimeout(() => setFlash(null), 700) }, [] ) React.useEffect(() => { let paused = store.get().paused || store.get().ended let scrubbing = store.get().scrubbing return store.subscribe(() => { const api = store.get() const nextPaused = api.paused || api.ended || api.error if (nextPaused !== paused || api.scrubbing !== scrubbing) { paused = nextPaused scrubbing = api.scrubbing if (paused || scrubbing) { clearTimeout(hideTimerRef.current) setControlsHidden(false) } else { schedule() } } }) }, [schedule, store]) React.useEffect(() => { const sync = () => { const root = rootRef.current store.set({ fullscreen: root !== null && fullscreenElement() === root, }) } document.addEventListener("fullscreenchange", sync) document.addEventListener("webkitfullscreenchange", sync) return () => { document.removeEventListener("fullscreenchange", sync) document.removeEventListener("webkitfullscreenchange", sync) } }, [store]) React.useEffect( () => () => { clearTimeout(hideTimerRef.current) clearTimeout(flashTimerRef.current) }, [] ) React.useEffect(() => { if (!shortcuts || !globalShortcuts) { return } const handleKeyDown = (event: KeyboardEvent) => { const target = event.target if ( target instanceof Element && target !== document.body && (target.closest("[data-slot=video-player]") !== null || isActivatable(target) || isRange(target)) ) { return } if ( document.querySelector("[role=dialog], [role=alertdialog], [role=menu]") ) { return } if (runShortcut(event, store.get(), notify)) { event.preventDefault() if (variant === "overlay") { reveal() } } } window.addEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown) }, [globalShortcuts, notify, reveal, shortcuts, store, variant]) const setRootRef = React.useCallback( (node: HTMLDivElement | null) => { rootRef.current = node if (typeof ref === "function") { const cleanup = ref(node) return () => { rootRef.current = null if (typeof cleanup === "function") { cleanup() } else { ref(null) } } } if (ref) { ref.current = node } return () => { rootRef.current = null if (ref && typeof ref !== "function") { ref.current = null } } }, [ref] ) const context = React.useMemo<VideoPlayerContextValue>( () => ({ store, variant, rootRef, videoRef, controlsHidden, hold, reveal, hideControls, notify, shortcuts, }), [ controlsHidden, hideControls, hold, notify, reveal, shortcuts, store, variant, ] ) return ( <VideoPlayerContext.Provider value={context}> <div ref={setRootRef} role="region" aria-label="Video player" tabIndex={-1} data-slot="video-player" data-variant={variant} data-controls={controlsHidden ? "hidden" : "visible"} data-fullscreen={fullscreen ? "" : undefined} aria-busy={waiting || undefined} className={cn(videoPlayerVariants({ variant }), className)} onKeyDown={(event) => { onKeyDown?.(event) if ( !shortcuts || event.defaultPrevented || !(event.target instanceof Node) || !event.currentTarget.contains(event.target) ) { return } if (runShortcut(event, store.get(), notify)) { event.preventDefault() if (variant === "overlay") { reveal() } } }} onPointerMove={(event) => { onPointerMove?.(event) if (event.pointerType === "mouse" && variant === "overlay") { reveal() } }} onPointerLeave={(event) => { onPointerLeave?.(event) if (event.pointerType === "mouse" && variant === "overlay") { schedule(600) } }} {...props} > {children} <div data-slot="video-player-status" dir="ltr" className="dark pointer-events-none row-start-1 grid place-items-center text-foreground *:col-start-1 *:row-start-1" > {!error && ( <span aria-hidden data-slot="video-player-poster-play" data-started={started ? "" : undefined} className="grid size-14 place-items-center rounded-full bg-background/60 backdrop-blur-sm transition-[opacity,scale] duration-200 ease-out-quint data-started:opacity-0 motion-safe:data-started:scale-90 motion-reduce:transition-opacity [&_svg]:size-6" > <IconPlayerPlayFilled /> </span> )} {!error && ( <Spinner aria-hidden loading={waiting} size="xl" data-slot="video-player-spinner" /> )} {flash && !error && ( <span key={flash.id} aria-hidden data-slot="video-player-flash" data-kind={flash.kind} data-side={flash.side} className={cn( "flex animate-video-player-flash items-center justify-center gap-1.5 rounded-full bg-background/60 font-medium tabular-nums backdrop-blur-sm motion-reduce:[--video-player-flash-from:1] motion-reduce:[--video-player-flash-to:1] [&_svg]:size-6", flash.text && flash.kind !== "play" && flash.kind !== "pause" ? "h-11 ps-3 pe-4 text-sm [&_svg]:size-5" : "size-14", flash.side === "start" && "ms-[8%] justify-self-start", flash.side === "end" && "me-[8%] justify-self-end" )} > <FlashIcon kind={flash.kind} /> {flash.kind !== "play" && flash.kind !== "pause" && flash.text} </span> )} {captions && caption && !error && ( <div data-slot="video-player-captions" data-lifted={ variant === "overlay" && !controlsHidden ? "" : undefined } className="mb-3 self-end px-4 text-center text-sm/snug text-foreground transition-[translate] duration-200 ease-out-quint in-data-fullscreen:text-2xl/snug data-lifted:-translate-y-[max(0px,calc(var(--video-player-controls-height,0px)-2rem))] motion-reduce:transition-none @md/video-player:text-base/snug @3xl/video-player:text-xl/snug" > <span className="rounded-sm bg-background/80 box-decoration-clone px-2 py-0.5 whitespace-pre-line"> {caption} </span> </div> )} {error && ( <div data-slot="video-player-error" className="flex max-w-xs flex-col items-center gap-2 px-6 text-center text-sm text-pretty text-muted-foreground [&_svg]:size-6" > <IconAlertTriangle aria-hidden className="text-foreground" /> {errorMessage} </div> )} </div> <span data-slot="video-player-announcer" role="status" className="sr-only" > {announcement} </span> <span data-slot="video-player-alert" role="alert" className="sr-only"> {error ? errorMessage : ""} </span> </div> </VideoPlayerContext.Provider> ) } function FlashIcon({ kind }: { kind: FlashKind }) { switch (kind) { case "play": return <IconPlayerPlayFilled /> case "pause": return <IconPlayerPauseFilled /> case "back": return <IconChevronsLeft /> case "forward": return <IconChevronsRight /> case "mute": return <IconVolume3 /> case "volume": return <IconVolume /> case "captions": return <IconBadgeCcFilled /> default: return null } } type VideoPlayerContentProps = useRender.ComponentProps<"video"> & { doubleTapSeek?: number | false } type Tap = { time: number side: -1 | 0 | 1 streak: number hiddenBefore: boolean playing: boolean } const doubleTapWindow = 300 function VideoPlayerContent({ className, render, ref, autoPlay = false, playsInline = true, preload = "metadata", doubleTapSeek = 10, onPointerDown, onClick, onDoubleClick, ...props }: VideoPlayerContentProps) { const { store, videoRef, notify, reveal, hideControls, controlsHidden } = useVideoPlayerContext("VideoPlayerContent") const pointerTypeRef = React.useRef("mouse") const tapRef = React.useRef<Tap | null>(null) const attach = React.useCallback( (video: HTMLVideoElement | null) => { if (!video) { return } videoRef.current = video let frame = 0 const read = () => ({ paused: video.paused, ended: video.ended, currentTime: video.currentTime, duration: Number.isNaN(video.duration) ? 0 : video.duration, volume: video.volume, muted: video.muted, playbackRate: video.playbackRate, buffered: readBuffered(video), error: video.error !== null, }) const tick = () => { store.set({ currentTime: video.currentTime }) frame = requestAnimationFrame(tick) } const startTicking = () => { cancelAnimationFrame(frame) if (typeof requestAnimationFrame === "function") { frame = requestAnimationFrame(tick) } } const stopTicking = () => cancelAnimationFrame(frame) const sync = () => store.set(read()) const handlers: Array<[string, () => void]> = [ ["loadedmetadata", sync], ["durationchange", sync], ["timeupdate", sync], ["progress", () => store.set({ buffered: readBuffered(video) })], ["volumechange", sync], ["ratechange", sync], ["seeked", sync], [ "play", () => { store.set({ ...read(), started: true, ended: false }) startTicking() }, ], [ "playing", () => { store.set({ ...read(), waiting: false }) startTicking() }, ], [ "pause", () => { stopTicking() store.set(read()) }, ], [ "ended", () => { stopTicking() store.set({ ...read(), ended: true, waiting: false }) }, ], ["waiting", () => store.set({ waiting: true })], ["canplay", () => store.set({ waiting: false })], [ "error", () => { stopTicking() store.set({ ...read(), waiting: false }) }, ], [ "emptied", () => { stopTicking() store.set({ ...read(), started: !video.paused, waiting: false, buffered: 0, }) }, ], ["enterpictureinpicture", () => store.set({ pictureInPicture: true })], ["leavepictureinpicture", () => store.set({ pictureInPicture: false })], ] for (const [type, handler] of handlers) { video.addEventListener(type, handler) } const sourceError = (event: Event) => { if ( event.target instanceof HTMLSourceElement && video.networkState === HTMLMediaElement.NETWORK_NO_SOURCE ) { store.set({ error: true, waiting: false }) } } video.addEventListener("error", sourceError, true) const tracks = typeof video.textTracks?.addEventListener === "function" ? video.textTracks : null const syncTracks = () => store.syncTracks() tracks?.addEventListener("addtrack", syncTracks) tracks?.addEventListener("removetrack", syncTracks) tracks?.addEventListener("change", syncTracks) store.syncTracks() store.set({ ...read(), started: !video.paused || video.currentTime > 0, error: video.error !== null || (video.networkState === HTMLMediaElement.NETWORK_NO_SOURCE && video.readyState === HTMLMediaElement.HAVE_NOTHING && video.querySelector("source") !== null), }) if (!video.paused) { startTicking() } return () => { stopTicking() for (const [type, handler] of handlers) { video.removeEventListener(type, handler) } video.removeEventListener("error", sourceError, true) tracks?.removeEventListener("addtrack", syncTracks) tracks?.removeEventListener("removetrack", syncTracks) tracks?.removeEventListener("change", syncTracks) if (videoRef.current === video) { videoRef.current = null store.syncTracks() } } }, [store, videoRef] ) const element = useRender({ defaultTagName: "video", render, ref: ref ? [attach, ref] : attach, props: mergeProps<"video">( { playsInline, preload, className: cn( "row-start-1 aspect-video size-full bg-black object-contain outline-none focus-visible:outline-hidden in-data-fullscreen:aspect-auto", className ), onPointerDown: (event) => { onPointerDown?.(event) pointerTypeRef.current = event.pointerType }, onClick: (event) => { onClick?.(event) if (event.defaultPrevented) { return } const api = store.get() if (api.error) { return } const playing = !(api.paused || api.ended) if (pointerTypeRef.current === "touch") { const now = event.timeStamp const rect = event.currentTarget.getBoundingClientRect() const ratio = rect.width > 0 ? (event.clientX - rect.left) / rect.width : 0.5 const side = ratio < 1 / 3 ? -1 : ratio > 2 / 3 ? 1 : 0 const last = tapRef.current if ( doubleTapSeek && side !== 0 && last && last.playing && last.side === side && now - last.time < doubleTapWindow && isSeekable(api.duration) ) { const streak = last.streak + 1 tapRef.current = { ...last, time: now, streak } api.seekBy(side * doubleTapSeek) const total = streak * doubleTapSeek notify( side < 0 ? "back" : "forward", `${side < 0 ? "−" : "+"}${total}s`, `${side < 0 ? "Back" : "Forward"} ${total} seconds`, side < 0 ? "start" : "end" ) if (last.hiddenBefore) { hideControls() } else { reveal() } return } tapRef.current = { time: now, side, streak: 0, hiddenBefore: controlsHidden, playing, } } if (pointerTypeRef.current === "touch" && playing) { if (controlsHidden) { reveal() } else { hideControls() } return } const willPlay = api.paused || api.ended api.togglePaused() notify(willPlay ? "play" : "pause", willPlay ? "Playing" : "Paused") }, onDoubleClick: (event) => { onDoubleClick?.(event) if ( !event.defaultPrevented && pointerTypeRef.current !== "touch" && !store.get().error ) { store.get().toggleFullscreen() } }, }, props, { "data-slot": "video-player-content" } as React.ComponentProps<"video"> ), }) React.useEffect(() => { const video = videoRef.current if (autoPlay && video && video.paused && !prefersReducedMotion()) { ignoreRejection(video.play()) } }, [autoPlay, videoRef]) return element } type VideoPlayerControlsProps = React.ComponentProps<"div"> & { tooltips?: boolean } function VideoPlayerControls({ className, tooltips = true, children, onPointerEnter, onPointerLeave, onFocus, onBlur, ref, ...props }: VideoPlayerControlsProps) { const { variant, controlsHidden, hold, rootRef } = useVideoPlayerContext( "VideoPlayerControls" ) const fullscreen = useVideoPlayerFullscreen() const context = React.useMemo(() => ({ tooltips }), [tooltips]) const measure = React.useCallback((node: HTMLDivElement | null) => { const root = node?.closest<HTMLElement>("[data-slot=video-player]") if (!node || !root || typeof ResizeObserver === "undefined") { return } const observer = new ResizeObserver(() => { root.style.setProperty( "--video-player-controls-height", `${node.offsetHeight}px` ) }) observer.observe(node) return () => { observer.disconnect() root.style.removeProperty("--video-player-controls-height") } }, []) const mergedRef = useMergedRef(measure, ref) const bar = ( <div ref={mergedRef} dir="ltr" data-slot="video-player-controls" data-variant={variant} data-hidden={controlsHidden ? "" : undefined} className={cn( "z-10 flex min-w-0 flex-wrap items-center gap-0.5 px-2 pb-2 transition-[opacity,translate] duration-200 ease-out-quint motion-reduce:transition-opacity", variant === "overlay" ? "pointer-events-none row-start-1 self-end bg-linear-to-t from-background/80 via-background/35 to-transparent pt-10 *:pointer-events-auto data-hidden:opacity-0 data-hidden:duration-300 data-hidden:*:pointer-events-none motion-safe:data-hidden:translate-y-1" : "row-start-2 pt-1", className )} onPointerEnter={(event) => { onPointerEnter?.(event) if (event.pointerType === "mouse") { hold("pointer", true) } }} onPointerLeave={(event) => { onPointerLeave?.(event) hold("pointer", false) }} onFocus={(event) => { onFocus?.(event) let visible = false try { visible = event.target.matches(":focus-visible") } catch { visible = false } hold("focus", visible) }} onBlur={(event) => { onBlur?.(event) if ( !(event.relatedTarget instanceof Node) || !event.currentTarget.contains(event.relatedTarget) ) { hold("focus", false) } }} {...props} > {children} </div> ) return ( <VideoPlayerControlsContext.Provider value={context}> {tooltips ? ( <TooltipGroup side="top" sideOffset={8} portalProps={{ container: fullscreen ? rootRef : undefined }} > {bar} </TooltipGroup> ) : ( bar )} </VideoPlayerControlsContext.Provider> ) } function useVideoPlayerFullscreen() { const { store } = useVideoPlayerContext("VideoPlayerControls") return useStoreValue(store, (api) => api.fullscreen) } type ControlButtonProps = React.ComponentProps<typeof Button> & { label: string shortcut?: string } function ControlButton({ label, shortcut, size = "icon-sm", variant = "ghost", ...props }: ControlButtonProps) { const controls = React.useContext(VideoPlayerControlsContext) const button = ( <Button variant={variant} size={size} aria-label={label} {...props} /> ) if (!controls?.tooltips) { return button } return ( <TooltipTrigger render={button} content={ <> {label} {shortcut && <Kbd keys={shortcut} size="sm" />} </> } /> ) } type VideoPlayerButtonProps = Omit< React.ComponentProps<typeof Button>, "children" > & { children?: React.ReactNode } type VideoPlayerPlayButtonProps = VideoPlayerButtonProps & { playLabel?: string pauseLabel?: string replayLabel?: string } function VideoPlayerPlayButton({ playLabel = "Play", pauseLabel = "Pause", replayLabel = "Replay", onClick, children, ...props }: VideoPlayerPlayButtonProps) { const { store } = useVideoPlayerContext("VideoPlayerPlayButton") const paused = useStoreValue(store, (api) => api.paused) const ended = useStoreValue(store, (api) => api.ended) const error = useStoreValue(store, (api) => api.error) const state = ended ? "ended" : paused ? "paused" : "playing" return ( <ControlButton data-slot="video-player-play-button" data-state={state} label={ended ? replayLabel : paused ? playLabel : pauseLabel} shortcut="k" disabled={error} onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) { store.get().togglePaused() } }} {...props} > {children ?? ( <span aria-hidden className="grid *:col-start-1 *:row-start-1 *:transition-[opacity,scale] *:duration-200 *:ease-out-quint motion-reduce:*:transition-opacity" > <IconPlayerPlayFilled className={cn( state !== "paused" && "opacity-0 motion-safe:scale-50" )} /> <IconPlayerPauseFilled className={cn( state !== "playing" && "opacity-0 motion-safe:scale-50" )} /> <IconRotateClockwise className={cn( state !== "ended" && "opacity-0 motion-safe:scale-50" )} /> </span> )} </ControlButton> ) } const seekIcons: Record<number, [React.ElementType, React.ElementType]> = { 5: [IconRewindBackward5, IconRewindForward5], 10: [IconRewindBackward10, IconRewindForward10], 15: [IconRewindBackward15, IconRewindForward15], 30: [IconRewindBackward30, IconRewindForward30], } type VideoPlayerSeekButtonProps = VideoPlayerButtonProps & { offset?: number label?: string } function VideoPlayerSeekButton({ offset = 10, label, className, onClick, children, ...props }: VideoPlayerSeekButtonProps) { const { store, notify } = useVideoPlayerContext("VideoPlayerSeekButton") const seekable = useStoreValue( store, (api) => isSeekable(api.duration) && !api.error ) const amount = Math.abs(offset) const backward = offset < 0 const icons = seekIcons[amount] const Icon = icons?.[backward ? 0 : 1] ?? IconRotateClockwise const resolvedLabel = label ?? `${backward ? "Back" : "Forward"} ${amount} ${amount === 1 ? "second" : "seconds"}` return ( <ControlButton data-slot="video-player-seek-button" data-direction={backward ? "backward" : "forward"} className={cn("@max-sm/video-player:hidden", className)} label={resolvedLabel} shortcut={amount === 10 ? (backward ? "j" : "l") : undefined} disabled={!seekable} onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) { store.get().seekBy(offset) notify( backward ? "back" : "forward", `${backward ? "−" : "+"}${amount}s`, resolvedLabel ) } }} {...props} > {children ?? ( <Icon aria-hidden className={cn(!icons && backward && "-scale-x-100")} /> )} </ControlButton> ) } type VideoPlayerMuteButtonProps = VideoPlayerButtonProps & { muteLabel?: string unmuteLabel?: string } function VideoPlayerMuteButton({ muteLabel = "Mute", unmuteLabel = "Unmute", onClick, children, ...props }: VideoPlayerMuteButtonProps) { const { store } = useVideoPlayerContext("VideoPlayerMuteButton") const level = useStoreValue(store, (api) => api.muted || api.volume === 0 ? "muted" : api.volume < 0.5 ? "low" : "high" ) return ( <ControlButton data-slot="video-player-mute-button" data-state={level} label={level === "muted" ? unmuteLabel : muteLabel} shortcut="m" onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) { store.get().toggleMuted() } }} {...props} > {children ?? (level === "muted" ? ( <IconVolume3 aria-hidden /> ) : level === "low" ? ( <IconVolume2 aria-hidden /> ) : ( <IconVolume aria-hidden /> ))} </ControlButton> ) } type SliderRestProps = Omit< SliderPrimitive.Root.Props<number>, | "value" | "defaultValue" | "min" | "max" | "step" | "largeStep" | "children" | "className" | "orientation" > & { className?: string label?: string } const sliderTrack = "relative h-1 w-full rounded-full bg-foreground/20 transition-[height] duration-150 ease-out-quint motion-reduce:transition-none" const sliderThumb = "size-3 rounded-full bg-foreground outline-none focus-visible:outline-hidden group-data-[variant=overlay]/video-player:shadow-sm transition-[scale,box-shadow] duration-150 ease-out-quint has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-foreground/40 motion-reduce:transition-none pointer-coarse:size-4" const volumeSteps = 0.05 type VideoPlayerSeekBarProps = SliderRestProps type VideoPlayerVolumeProps = SliderRestProps & { muteLabel?: string unmuteLabel?: string } function VideoPlayerVolume({ className, label = "Volume", muteLabel, unmuteLabel, onValueChange, disabled, ...props }: VideoPlayerVolumeProps) { const { store } = useVideoPlayerContext("VideoPlayerVolume") const volume = useStoreValue(store, (api) => (api.muted ? 0 : api.volume)) return ( <div data-slot="video-player-volume" className={cn("group/volume flex items-center", className)} > <VideoPlayerMuteButton muteLabel={muteLabel} unmuteLabel={unmuteLabel} /> <div className="grid w-0 overflow-hidden transition-[width] duration-200 ease-out-quint group-focus-within/volume:w-20 group-hover/volume:w-20 motion-reduce:transition-none pointer-coarse:hidden"> <SliderPrimitive.Root data-slot="video-player-volume-slider" value={Math.round(volume * 100)} min={0} max={100} step={1} largeStep={10} disabled={disabled} onValueChange={(value, details) => { onValueChange?.(value, details) if (!details.isCanceled) { store.get().setVolume(value / 100) } }} className="w-20 px-2.5" {...props} > <SliderPrimitive.Control className="flex h-8 w-full touch-none items-center"> <SliderPrimitive.Track className={sliderTrack}> <SliderPrimitive.Indicator className="rounded-full bg-foreground" /> <SliderPrimitive.Thumb aria-label={label} getAriaValueText={(_, value) => `${Math.round(value)}%`} onKeyDown={(event) => { if ( event.key === "ArrowUp" || event.key === "ArrowRight" || event.key === "ArrowDown" || event.key === "ArrowLeft" ) { if (event.shiftKey) { return } event.preventDefault() const up = event.key === "ArrowUp" || event.key === "ArrowRight" const api = store.get() const base = api.muted ? 0 : api.volume api.setVolume( Math.round( (base + (up ? volumeSteps : -volumeSteps)) * 100 ) / 100 ) } }} className={sliderThumb} /> </SliderPrimitive.Track> </SliderPrimitive.Control> </SliderPrimitive.Root> </div> </div> ) } function VideoPlayerSeekBar({ className, label = "Seek", onValueChange, onValueCommitted, disabled, ...props }: VideoPlayerSeekBarProps) { const { store, videoRef, hold } = useVideoPlayerContext("VideoPlayerSeekBar") const time = useStoreValue(store, (api) => api.scrubbing ? api.scrubTime : api.currentTime ) const duration = useStoreValue(store, (api) => api.duration) const buffered = useStoreValue(store, (api) => api.buffered) const error = useStoreValue(store, (api) => api.error) const seekable = isSeekable(duration) && !error const controlRef = React.useRef<HTMLDivElement>(null) const previewRef = React.useRef<HTMLSpanElement>(null) React.useLayoutEffect(() => { controlRef.current?.style.setProperty( "--video-player-buffered", String(buffered) ) }, [buffered]) const showPreview = (clientX: number) => { const control = controlRef.current const preview = previewRef.current if (!control || !preview || !seekable) { return } const rect = control.getBoundingClientRect() if (rect.width === 0) { return } const ratio = clamp((clientX - rect.left) / rect.width, 0, 1) preview.textContent = formatTime(ratio * duration, duration) const half = preview.offsetWidth / 2 const x = clamp(ratio * rect.width, half, Math.max(half, rect.width - half)) control.style.setProperty("--video-player-hover", String(ratio)) preview.style.translate = `${x - half}px 0` control.setAttribute("data-previewing", "") } const finishScrub = () => { const api = store.get() if (api.scrubbing) { store.set({ scrubbing: false, currentTime: api.scrubTime }) } } return ( <SliderPrimitive.Root dir="ltr" data-slot="video-player-seek-bar" value={seekable ? clamp(time, 0, duration) : 0} min={0} max={seekable ? duration : 1} step={0.01} disabled={disabled || !seekable} onValueChange={(value, details) => { onValueChange?.(value, details) if (details.isCanceled) { return } const video = videoRef.current if (!video) { return } if (details.reason === "drag" || details.reason === "track-press") { store.set({ scrubbing: true, scrubTime: value, ended: false }) const fast = ( video as HTMLVideoElement & { fastSeek?: (time: number) => void } ).fastSeek if (details.reason === "drag" && typeof fast === "function") { fast.call(video, value) } else { video.currentTime = value } } else { store.get().seek(value) } }} onValueCommitted={(value, details) => { onValueCommitted?.(value, details) const video = videoRef.current if (video && store.get().scrubbing) { video.currentTime = value store.set({ currentTime: value }) } finishScrub() }} className={cn("order-first basis-full px-1", className)} {...props} > <SliderPrimitive.Control ref={controlRef} data-slot="video-player-seek-bar-control" className="group/seek relative flex h-5 w-full touch-none items-center pointer-coarse:h-8" onPointerMove={(event) => showPreview(event.clientX)} onPointerDown={(event) => { showPreview(event.clientX) hold("scrub", true) }} onPointerUp={() => { hold("scrub", false) finishScrub() }} onPointerCancel={() => { hold("scrub", false) finishScrub() }} onPointerLeave={(event) => { if (!event.currentTarget.hasAttribute("data-dragging")) { event.currentTarget.removeAttribute("data-previewing") } }} onLostPointerCapture={(event) => { hold("scrub", false) event.currentTarget.removeAttribute("data-previewing") }} > <SliderPrimitive.Track className={cn( sliderTrack, "group-hover/seek:h-1.5 group-data-dragging/seek:h-1.5" )} > <span aria-hidden data-slot="video-player-seek-bar-buffered" className="absolute inset-y-0 start-0 w-[calc(var(--video-player-buffered,0)*100%)] rounded-full bg-foreground/25" /> <span aria-hidden data-slot="video-player-seek-bar-hover" className="absolute inset-y-0 start-0 w-[calc(var(--video-player-hover,0)*100%)] rounded-full bg-foreground/20 opacity-0 transition-opacity duration-150 group-data-previewing/seek:opacity-100 pointer-coarse:hidden" /> <SliderPrimitive.Indicator data-slot="video-player-seek-bar-indicator" className="rounded-full bg-foreground" /> <SliderPrimitive.Thumb aria-label={label} getAriaValueText={(_, value) => `${spokenTime(value)} of ${spokenTime(duration)}` } onKeyDown={(event) => { if ( event.key === "ArrowLeft" || event.key === "ArrowRight" || event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "PageUp" || event.key === "PageDown" ) { event.preventDefault() const forward = event.key === "ArrowRight" || event.key === "ArrowUp" || event.key === "PageUp" const amount = event.shiftKey || event.key === "PageUp" || event.key === "PageDown" ? 10 : 5 store.get().seekBy(forward ? amount : -amount) } }} className={cn( sliderThumb, "scale-0 group-hover/seek:scale-100 group-data-dragging/seek:scale-100 has-[:focus-visible]:scale-100 pointer-coarse:scale-100" )} /> </SliderPrimitive.Track> <span ref={previewRef} aria-hidden data-slot="video-player-seek-bar-preview" className="pointer-events-none absolute start-0 bottom-full mb-1.5 rounded-md bg-foreground px-1.5 py-0.5 text-xs font-medium text-background tabular-nums opacity-0 transition-opacity duration-150 group-data-previewing/seek:opacity-100 pointer-coarse:not-group-data-dragging/seek:opacity-0 pointer-coarse:group-data-dragging/seek:opacity-100" /> </SliderPrimitive.Control> </SliderPrimitive.Root> ) } type VideoPlayerTimeProps = React.ComponentProps<"span"> & { type?: "both" | "elapsed" | "remaining" | "duration" } function VideoPlayerTime({ className, type = "both", ...props }: VideoPlayerTimeProps) { const { store } = useVideoPlayerContext("VideoPlayerTime") const seconds = useStoreValue(store, (api) => Math.floor(api.scrubbing ? api.scrubTime : api.currentTime) ) const duration = useStoreValue(store, (api) => api.duration) const known = isSeekable(duration) const total = known ? formatTime(duration) : "--:--" const elapsed = formatTime(seconds, duration) const remaining = known ? `-${formatTime(Math.max(0, Math.ceil(duration) - seconds), duration)}` : "--:--" const text = type === "elapsed" ? elapsed : type === "remaining" ? remaining : type === "duration" ? total : `${elapsed} / ${total}` return ( <span dir="ltr" data-slot="video-player-time" data-type={type} className={cn( "shrink-0 px-1.5 text-xs font-medium whitespace-nowrap text-foreground/90 tabular-nums", className )} {...props} > {text} </span> ) } function VideoPlayerSpacer({ className, ...props }: React.ComponentProps<"div">) { return ( <div aria-hidden data-slot="video-player-spacer" className={cn("flex-1", className)} {...props} /> ) } type VideoPlayerPlaybackRateProps = VideoPlayerButtonProps & { rates?: number[] label?: string normalLabel?: string } function VideoPlayerPlaybackRate({ rates = defaultRates, label = "Playback speed", normalLabel = "Normal", className, ...props }: VideoPlayerPlaybackRateProps) { const { store, hold, rootRef } = useVideoPlayerContext( "VideoPlayerPlaybackRate" ) const rate = useStoreValue(store, (api) => api.playbackRate) const fullscreen = useStoreValue(store, (api) => api.fullscreen) const options = React.useMemo( () => Array.from( new Set(rates.filter((value) => Number.isFinite(value) && value > 0)) ).sort((a, b) => a - b), [rates] ) return ( <DropdownMenu onOpenChange={(open) => hold("menu", open)}> <DropdownMenuTrigger render={ <Button variant="ghost" size="sm" aria-label={`${label} ${formatRate(rate)}`} data-slot="video-player-playback-rate" className={cn("min-w-10 px-2 tabular-nums", className)} {...props} /> } > {formatRate(rate)} </DropdownMenuTrigger> <DropdownMenuContent side="top" align="end" sideOffset={8} portalProps={{ container: fullscreen ? rootRef : undefined }} className="min-w-32" > <DropdownMenuRadioGroup value={String(rate)} onValueChange={(value) => store.get().setPlaybackRate(Number(value))} > {options.map((value) => ( <DropdownMenuRadioItem key={value} value={String(value)} closeOnClick className="tabular-nums" > {value === 1 ? normalLabel : formatRate(value)} </DropdownMenuRadioItem> ))} </DropdownMenuRadioGroup> </DropdownMenuContent> </DropdownMenu> ) } type VideoPlayerCaptionsButtonProps = VideoPlayerButtonProps & { label?: string } function VideoPlayerCaptionsButton({ label = "Captions", onClick, children, ...props }: VideoPlayerCaptionsButtonProps) { const { store } = useVideoPlayerContext("VideoPlayerCaptionsButton") const available = useStoreValue(store, (api) => api.hasCaptions) const active = useStoreValue(store, (api) => api.captions) if (!available) { return null } return ( <ControlButton data-slot="video-player-captions-button" data-state={active ? "on" : "off"} aria-pressed={active} label={label} shortcut="c" onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) { store.get().toggleCaptions() } }} {...props} > {children ?? (active ? ( <IconBadgeCcFilled aria-hidden /> ) : ( <IconBadgeCc aria-hidden /> ))} </ControlButton> ) } type VideoPlayerPictureInPictureButtonProps = VideoPlayerButtonProps & { enterLabel?: string exitLabel?: string } function VideoPlayerPictureInPictureButton({ enterLabel = "Picture in picture", exitLabel = "Exit picture in picture", onClick, children, ...props }: VideoPlayerPictureInPictureButtonProps) { const { store } = useVideoPlayerContext("VideoPlayerPictureInPictureButton") const supported = useSupport(canPictureInPicture) const active = useStoreValue(store, (api) => api.pictureInPicture) const error = useStoreValue(store, (api) => api.error) if (!supported) { return null } return ( <ControlButton data-slot="video-player-pip-button" data-state={active ? "on" : "off"} label={active ? exitLabel : enterLabel} shortcut="i" disabled={error} onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) { store.get().togglePictureInPicture() } }} {...props} > {children ?? (active ? ( <IconPictureInPictureOff aria-hidden /> ) : ( <IconPictureInPictureOn aria-hidden /> ))} </ControlButton> ) } type VideoPlayerFullscreenButtonProps = VideoPlayerButtonProps & { enterLabel?: string exitLabel?: string } function VideoPlayerFullscreenButton({ enterLabel = "Full screen", exitLabel = "Exit full screen", onClick, children, ...props }: VideoPlayerFullscreenButtonProps) { const { store } = useVideoPlayerContext("VideoPlayerFullscreenButton") const supported = useSupport(canFullscreen) const active = useStoreValue(store, (api) => api.fullscreen) if (!supported) { return null } return ( <ControlButton data-slot="video-player-fullscreen-button" data-state={active ? "on" : "off"} label={active ? exitLabel : enterLabel} shortcut="f" onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) { store.get().toggleFullscreen() } }} {...props} > {children ?? (active ? <IconMinimize aria-hidden /> : <IconMaximize aria-hidden />)} </ControlButton> ) } export { formatTime, useVideoPlayer, VideoPlayer, VideoPlayerCaptionsButton, VideoPlayerContent, VideoPlayerControls, VideoPlayerFullscreenButton, VideoPlayerMuteButton, VideoPlayerPictureInPictureButton, VideoPlayerPlaybackRate, VideoPlayerPlayButton, VideoPlayerSeekBar, VideoPlayerSeekButton, VideoPlayerSpacer, VideoPlayerTime, VideoPlayerVolume, videoPlayerVariants, } export type { VideoPlayerActions, VideoPlayerApi, VideoPlayerCaptionsButtonProps, VideoPlayerContentProps, VideoPlayerControlsProps, VideoPlayerMediaState, VideoPlayerProps, VideoPlayerSeekBarProps, VideoPlayerVariant, VideoPlayerVolumeProps, }components/ui/button.tsx "use client" import * as React from "react" import { Button as ButtonPrimitive } from "@base-ui/react/button" import { IconAlertCircle, IconCircleCheck } from "@tabler/icons-react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { Spinner } from "@/components/ui/spinner" import { useButtonFeedback, type ButtonFeedbackOptions, type ButtonStatus, } from "@/hooks/use-button-feedback" const buttonVariants = cva( "group/button relative inline-flex items-center justify-center rounded-md bg-clip-padding text-sm font-medium whitespace-nowrap inset-ring-(length:--hairline) inset-ring-transparent transition-[color,background-color,box-shadow,opacity,translate,scale] duration-200 ease-out-quint outline-none select-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden active:duration-100 active:not-aria-[haspopup]:not-aria-busy:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-busy:cursor-progress aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:inset-ring-destructive motion-safe:active:not-aria-[haspopup]:not-aria-busy:scale-[0.97] dark:aria-invalid:ring-destructive/40 dark:aria-invalid:inset-ring-destructive/50 forced-colors:border [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/80", outline: "bg-background inset-ring-border hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30 dark:inset-ring-input dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", ghost: "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 forced-colors:border-0", destructive: "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/70", link: "text-primary underline-offset-4 hover:underline forced-colors:border-0", success: "bg-success text-success-foreground hover:bg-success/90 focus-visible:ring-success/70 focus-visible:inset-ring-success/40", error: "bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 motion-safe:animate-button-shake", }, size: { default: "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2", xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3", sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5", lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2", icon: "size-9 shrink-0", "icon-xs": "size-6 shrink-0 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-2.5 pointer-coarse:in-data-[slot=button-group]:after:hidden [&_svg:not([class*='size-'])]:size-3", "icon-sm": "size-8 shrink-0 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-1.5 pointer-coarse:in-data-[slot=button-group]:after:hidden", "icon-lg": "size-10 shrink-0", }, }, defaultVariants: { variant: "default", size: "default", }, } ) type ButtonVariant = Exclude< NonNullable<VariantProps<typeof buttonVariants>["variant"]>, "success" | "error" > type ButtonClickEvent = Parameters< NonNullable<ButtonPrimitive.Props["onClick"]> >[0] type ButtonProps = Omit<ButtonPrimitive.Props, "onClick"> & Omit<VariantProps<typeof buttonVariants>, "variant"> & ButtonFeedbackOptions & { variant?: ButtonVariant | null onClick?: (event: ButtonClickEvent) => unknown feedback?: boolean loading?: boolean status?: ButtonStatus loadingLabel?: React.ReactNode successLabel?: React.ReactNode errorLabel?: React.ReactNode | ((error: unknown) => React.ReactNode) } const stateTransitionTime = 220 const statuses: ButtonStatus[] = ["idle", "loading", "success", "error"] const quietVariants: ButtonVariant[] = ["ghost", "link"] const quietStatusClasses: Partial<Record<ButtonStatus, string>> = { success: "text-success hover:text-success", error: "text-destructive hover:text-destructive motion-safe:animate-button-shake", } function isThenable(value: unknown): value is PromiseLike<unknown> { return ( typeof value === "object" && value !== null && typeof (value as PromiseLike<unknown>).then === "function" ) } function useStatusTransition(status: ButtonStatus) { const [shown, setShown] = React.useState(status) const [leaving, setLeaving] = React.useState<ButtonStatus | null>(null) const [transitionKey, setTransitionKey] = React.useState(0) if (status !== shown) { setLeaving(shown) setShown(status) setTransitionKey((key) => key + 1) } React.useEffect(() => { if (leaving === null) { return } const timer = setTimeout(() => setLeaving(null), stateTransitionTime) return () => clearTimeout(timer) }, [leaving, transitionKey]) return { leaving, transitionKey } } function useLayerWidth(status: ButtonStatus, active: boolean) { const contentRef = React.useRef<HTMLSpanElement>(null) const layersRef = React.useRef(new Map<ButtonStatus, HTMLElement>()) const observerRef = React.useRef<ResizeObserver | null>(null) const statusRef = React.useRef(status) const applyWidth = React.useCallback(() => { const content = contentRef.current const layer = layersRef.current.get(statusRef.current) if (!content || !layer) { return } const natural = parseFloat(getComputedStyle(layer).width) if (!Number.isFinite(natural)) { return } content.style.width = `${natural}px` if (!content.hasAttribute("data-measured")) { requestAnimationFrame(() => content.setAttribute("data-measured", "")) } }, []) React.useLayoutEffect(() => { statusRef.current = status if (active) { applyWidth() } }, [active, applyWidth, status]) React.useLayoutEffect(() => { if (!active || typeof ResizeObserver === "undefined") { return } const observer = new ResizeObserver(applyWidth) observerRef.current = observer layersRef.current.forEach((layer) => observer.observe(layer)) document.fonts?.addEventListener?.("loadingdone", applyWidth) return () => { observer.disconnect() observerRef.current = null document.fonts?.removeEventListener?.("loadingdone", applyWidth) } }, [active, applyWidth]) const layerRef = React.useCallback((node: HTMLElement | null) => { if (!node) { return } const layer = node.dataset.layer as ButtonStatus layersRef.current.set(layer, node) observerRef.current?.observe(node) return () => { observerRef.current?.unobserve(node) if (layersRef.current.get(layer) === node) { layersRef.current.delete(layer) } } }, []) return { contentRef, layerRef } } function composeHandlers<Event>( first: ((event: Event) => void) | undefined, second: (event: Event) => void ) { return (event: Event) => { first?.(event) second(event) } } function Button({ className, variant = "default", size = "default", children, onClick, disabled, focusableWhenDisabled, feedback, loading, status: statusProp, onStatusChange, onError, resetAfter, loadingLabel, successLabel = "Done", errorLabel = "Failed", onPointerEnter, onPointerLeave, onFocus, onBlur, ...props }: ButtonProps) { const internal = useButtonFeedback({ resetAfter, onStatusChange, onError }) const status = statusProp ?? (loading ? "loading" : internal.status) const requested = Boolean(feedback) || loading !== undefined || statusProp !== undefined const [enabled, setEnabled] = React.useState(requested) if (requested && !enabled) { setEnabled(true) } const { leaving, transitionKey } = useStatusTransition(status) const iconOnly = size?.startsWith("icon") ?? false const widthStatus = status === "loading" && loadingLabel === undefined ? "idle" : status const { contentRef, layerRef } = useLayerWidth( widthStatus, enabled && !iconOnly ) const isLoading = status === "loading" const quiet = quietVariants.includes(variant ?? "default") const resolvedVariant = !quiet && status === "success" ? "success" : !quiet && status === "error" ? "error" : variant const resolvedErrorLabel = typeof errorLabel !== "function" ? errorLabel : internal.error !== undefined ? errorLabel(internal.error) : "Failed" const handleClick = (event: ButtonClickEvent) => { if (feedback && internal.isPending()) { return } const result = onClick?.(event) if (feedback && isThenable(result)) { internal.track(result) } } const layerContent = (layer: ButtonStatus, animated: boolean) => { if (layer === "idle") { return children } if (layer === "loading") { return ( <> <Spinner aria-hidden size={null} animated={animated} /> {iconOnly ? null : loadingLabel} </> ) } if (layer === "success") { return ( <> <IconCircleCheck className={cn( animated && "motion-safe:[&>path:first-child]:animate-button-draw-circle motion-safe:[&>path:first-child]:[stroke-dasharray:57] motion-safe:[&>path:first-child]:[stroke-dashoffset:57] motion-safe:[&>path:last-child]:animate-button-draw-check motion-safe:[&>path:last-child]:[stroke-dasharray:9] motion-safe:[&>path:last-child]:[stroke-dashoffset:9]" )} /> {iconOnly ? null : successLabel} </> ) } return ( <> <IconAlertCircle /> {iconOnly ? null : resolvedErrorLabel} </> ) } const button = ( <ButtonPrimitive data-slot="button" data-status={enabled ? status : undefined} aria-busy={isLoading || undefined} disabled={disabled || isLoading} focusableWhenDisabled={focusableWhenDisabled ?? (isLoading || undefined)} className={cn( buttonVariants({ variant: resolvedVariant, size }), quiet && quietStatusClasses[status], className )} onClick={handleClick} onPointerEnter={composeHandlers( onPointerEnter, internal.buttonProps.onPointerEnter )} onPointerLeave={composeHandlers( onPointerLeave, internal.buttonProps.onPointerLeave )} onFocus={composeHandlers(onFocus, internal.buttonProps.onFocus)} onBlur={composeHandlers(onBlur, internal.buttonProps.onBlur)} {...props} > {enabled ? ( <span ref={contentRef} data-slot="button-content" className="relative inline-grid grid-cols-[minmax(0,1fr)] place-items-center gap-[inherit] overflow-x-clip *:col-start-1 *:row-start-1 motion-safe:data-measured:transition-[width] motion-safe:data-measured:duration-220 motion-safe:data-measured:ease-[cubic-bezier(0.2,0,0,1)]" > {statuses.map((layer) => { const active = layer === status const exiting = layer === leaving const namesButton = layer === "idle" && isLoading && loadingLabel === undefined return ( <span key={active ? `${layer}-${transitionKey}` : layer} ref={layerRef} data-slot="button-layer" data-layer={layer} aria-hidden={active || namesButton ? undefined : true} className={cn( "inline-flex items-center justify-center gap-[inherit] backface-hidden", !active && !exiting && "pointer-events-none absolute opacity-0", active && transitionKey > 0 && "origin-bottom motion-safe:animate-button-state-in motion-reduce:animate-in motion-reduce:animation-duration-150 motion-reduce:fade-in-0", exiting && "pointer-events-none origin-top opacity-0 motion-safe:animate-button-state-out" )} > {layerContent(layer, active || exiting)} </span> ) })} </span> ) : ( children )} </ButtonPrimitive> ) if (!enabled) { return button } const announcement = status === "loading" ? typeof loadingLabel === "string" ? loadingLabel : "Loading" : status === "success" ? typeof successLabel === "string" ? successLabel : "Done" : status === "error" ? typeof resolvedErrorLabel === "string" ? resolvedErrorLabel : "Failed" : "" return ( <> {button} <span data-slot="button-status" role="status" className="sr-only"> {announcement} </span> </> ) } export { Button, buttonVariants, useButtonFeedback } export type { ButtonProps, ButtonStatus, ButtonFeedbackOptions }components/ui/dropdown-menu.tsx "use client" import * as React from "react" import { DirectionProvider, useDirection, type TextDirection, } from "@base-ui/react/direction-provider" import { Menu as MenuPrimitive } from "@base-ui/react/menu" import { mergeProps } from "@base-ui/react/merge-props" import { useRender } from "@base-ui/react/use-render" import { IconCheck, IconChevronRight, IconCircleFilled, } from "@tabler/icons-react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { SafeAreaOverlay } from "@/components/ui/safe-area" type ClassName<State> = string | ((state: State) => string | undefined) | undefined function mergeClassName<State>(base: string, className: ClassName<State>) { return typeof className === "function" ? (state: State) => cn(base, className(state)) : cn(base, className) } type DropdownMenuContextValue = { direction: TextDirection chosen: string | null choose: (id: string) => void } const DropdownMenuContext = React.createContext<DropdownMenuContextValue | null>(null) const DropdownMenuGroupContext = React.createContext(false) function useDropdownMenuContext(part: string) { const context = React.useContext(DropdownMenuContext) if (!context) { throw new Error(`${part} must be used within <DropdownMenu>.`) } return context } function readDirection(element: Element | null | undefined) { if (!element || !element.isConnected) { return undefined } return getComputedStyle(element).direction === "rtl" ? "rtl" : "ltr" } function getSubmenuElements() { const triggers = document.querySelectorAll( "[data-slot=dropdown-menu-sub-trigger][data-popup-open]" ) const reference = triggers[triggers.length - 1] const id = reference?.getAttribute("aria-controls") const floating = id ? document.getElementById(id) : null return reference && floating ? { reference, floating } : null } type DropdownMenuProps<Payload> = MenuPrimitive.Root.Props<Payload> & { showSafeArea?: boolean } function DropdownMenu<Payload>({ onOpenChange, onOpenChangeComplete, showSafeArea = false, children, ...props }: DropdownMenuProps<Payload>) { const [open, setOpen] = React.useState(false) const inherited = useDirection() const [triggerDirection, setTriggerDirection] = React.useState<TextDirection>() const [chosen, setChosen] = React.useState<string | null>(null) const direction = triggerDirection === "rtl" ? "rtl" : inherited const context = React.useMemo( () => ({ direction, chosen, choose: setChosen }), [direction, chosen] ) return ( <DropdownMenuContext.Provider value={context}> <DirectionProvider direction={direction}> <MenuPrimitive.Root onOpenChange={(open, eventDetails) => { onOpenChange?.(open, eventDetails) if (!eventDetails.isCanceled) { setOpen(open) } if (open && !eventDetails.isCanceled) { setChosen(null) if (eventDetails.trigger) { setTriggerDirection(readDirection(eventDetails.trigger)) } } }} onOpenChangeComplete={(open) => { onOpenChangeComplete?.(open) if (!open) { setChosen(null) } }} {...props} > {children} </MenuPrimitive.Root> {showSafeArea && (props.open ?? open) ? ( <SafeAreaOverlay getElements={getSubmenuElements} /> ) : null} </DirectionProvider> </DropdownMenuContext.Provider> ) } function DropdownMenuTrigger<Payload>( props: MenuPrimitive.Trigger.Props<Payload> ) { return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} /> } function DropdownMenuPortal(props: MenuPrimitive.Portal.Props) { return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} /> } type DropdownMenuContentProps = MenuPrimitive.Popup.Props & Pick< MenuPrimitive.Positioner.Props, | "side" | "align" | "sideOffset" | "alignOffset" | "anchor" | "collisionPadding" | "collisionAvoidance" | "sticky" > & { portalProps?: Omit<MenuPrimitive.Portal.Props, "children"> } const dropdownMenuContentClassName = "relative max-h-(--available-height) max-w-[min(var(--available-width),20rem)] min-w-40 origin-(--transform-origin) overflow-x-hidden overflow-y-auto overscroll-none rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-(length:--hairline) forced-colors:border ring-foreground/10 transition-[opacity,scale] duration-150 ease-out-quint outline-none focus-visible:outline-hidden [--dropdown-menu-item-radius:max(calc(var(--radius-sm)*0.5),calc(var(--radius-lg)-0.25rem))] data-ending-style:opacity-0 data-starting-style:opacity-0 data-ending-style:data-instant:transition-none motion-safe:data-starting-style:scale-96 motion-safe:data-[chosen]:data-ending-style:delay-120 motion-reduce:transition-opacity" const SIDE_COLLISION_AVOIDANCE = { fallbackAxisSide: "end" } as const function DropdownMenuPopup({ slot, className, side, align, sideOffset, alignOffset, anchor, collisionPadding = 8, collisionAvoidance, sticky, portalProps, ...props }: DropdownMenuContentProps & { slot: string }) { const { direction, chosen } = useDropdownMenuContext( slot === "dropdown-menu-content" ? "DropdownMenuContent" : "DropdownMenuSubContent" ) return ( <MenuPrimitive.Portal {...portalProps}> <MenuPrimitive.Positioner data-slot={slot.replace("content", "positioner")} dir={direction === "rtl" ? "rtl" : undefined} side={side} align={align} sideOffset={sideOffset} alignOffset={alignOffset} anchor={anchor} collisionPadding={collisionPadding} collisionAvoidance={ collisionAvoidance ?? (side === "bottom" || side === "top" ? undefined : SIDE_COLLISION_AVOIDANCE) } sticky={sticky} className="isolate z-50 outline-none focus-visible:outline-hidden" > <MenuPrimitive.Popup data-slot={slot} data-chosen={chosen === null ? undefined : ""} className={mergeClassName( cn( dropdownMenuContentClassName, slot === "dropdown-menu-content" && "min-w-[max(var(--anchor-width),10rem)]" ), className )} {...props} /> </MenuPrimitive.Positioner> </MenuPrimitive.Portal> ) } function DropdownMenuContent({ side = "bottom", align = "start", sideOffset = 4, alignOffset = 0, ...props }: DropdownMenuContentProps) { return ( <DropdownMenuPopup slot="dropdown-menu-content" side={side} align={align} sideOffset={sideOffset} alignOffset={alignOffset} {...props} /> ) } function DropdownMenuGroup(props: MenuPrimitive.Group.Props) { return ( <DropdownMenuGroupContext.Provider value> <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} /> </DropdownMenuGroupContext.Provider> ) } type DropdownMenuLabelProps = MenuPrimitive.GroupLabel.Props & { inset?: boolean } const dropdownMenuLabelClassName = "px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground wrap-anywhere data-inset:ps-8" function DropdownMenuStandaloneLabel({ className, render, ...props }: MenuPrimitive.GroupLabel.Props) { const resolved = mergeClassName(dropdownMenuLabelClassName, className) return useRender({ defaultTagName: "div", render, props: mergeProps<"div">( { className: typeof resolved === "function" ? resolved({}) : resolved, }, props as React.ComponentProps<"div"> ), }) } function DropdownMenuLabel({ inset, ...props }: DropdownMenuLabelProps) { const inGroup = React.useContext(DropdownMenuGroupContext) const Label = inGroup ? MenuPrimitive.GroupLabel : DropdownMenuStandaloneLabel return ( <Label data-slot="dropdown-menu-label" data-inset={inset ? "" : undefined} {...props} className={ inGroup ? mergeClassName(dropdownMenuLabelClassName, props.className) : props.className } /> ) } const dropdownMenuItemVariants = cva( "relative flex min-h-8 w-full min-w-0 cursor-default items-center gap-2 rounded-(--dropdown-menu-item-radius) px-2 py-1.5 text-start text-sm wrap-anywhere outline-none select-none focus-visible:outline-hidden data-highlighted:bg-accent data-highlighted:text-accent-foreground data-inset:ps-8 motion-safe:data-[chosen]:animate-menu-blink forced-colors:data-highlighted:outline-2 forced-colors:data-highlighted:-outline-offset-2 forced-colors:data-highlighted:outline-solid pointer-coarse:min-h-11 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-highlighted:[&_svg:not([class*='text-'])]:text-accent-foreground", { variants: { variant: { default: "", destructive: "text-destructive [--menu-blink:color-mix(in_oklab,var(--color-destructive)_10%,transparent)] data-highlighted:bg-destructive/10 data-highlighted:text-destructive dark:[--menu-blink:color-mix(in_oklab,var(--color-destructive)_20%,transparent)] dark:data-highlighted:bg-destructive/20 forced-colors:data-highlighted:outline-2 forced-colors:data-highlighted:-outline-offset-2 forced-colors:data-highlighted:outline-solid [&_svg:not([class*='text-'])]:text-destructive data-highlighted:[&_svg:not([class*='text-'])]:text-destructive", }, }, defaultVariants: { variant: "default", }, } ) type ChoosableEvent = React.MouseEvent<HTMLElement> & { baseUIHandlerPrevented?: boolean } function useChooseOnClick<E extends ChoosableEvent>( closeOnClick: boolean, onClick: ((event: E) => void) | undefined ) { const context = React.useContext(DropdownMenuContext) const id = React.useId() return { chosen: context?.chosen === id ? "" : undefined, onClick: (event: E) => { onClick?.(event) if ( context && closeOnClick && event.detail !== 0 && !event.defaultPrevented && !event.baseUIHandlerPrevented ) { context.choose(id) } }, } } type DropdownMenuItemProps = MenuPrimitive.Item.Props & VariantProps<typeof dropdownMenuItemVariants> & { inset?: boolean } function DropdownMenuItem({ className, variant = "default", inset, closeOnClick = true, onClick, ...props }: DropdownMenuItemProps) { const choice = useChooseOnClick(closeOnClick, onClick) return ( <MenuPrimitive.Item data-slot="dropdown-menu-item" data-variant={variant} data-inset={inset ? "" : undefined} data-chosen={choice.chosen} closeOnClick={closeOnClick} onClick={choice.onClick} className={mergeClassName( dropdownMenuItemVariants({ variant }), className )} {...props} /> ) } const dropdownMenuIndicatorClassName = "flex items-center justify-center opacity-0 transition-[opacity,scale] duration-150 ease-out-quint data-checked:opacity-100 motion-safe:scale-50 motion-safe:data-checked:scale-100 motion-reduce:transition-none" type DropdownMenuCheckboxItemProps = MenuPrimitive.CheckboxItem.Props & { inset?: boolean } function DropdownMenuCheckboxItem({ className, children, inset, closeOnClick = false, onClick, ...props }: DropdownMenuCheckboxItemProps) { const choice = useChooseOnClick(closeOnClick, onClick) return ( <MenuPrimitive.CheckboxItem data-slot="dropdown-menu-checkbox-item" data-inset={inset ? "" : undefined} data-chosen={choice.chosen} closeOnClick={closeOnClick} onClick={choice.onClick} className={mergeClassName( cn(dropdownMenuItemVariants(), "ps-8"), className )} {...props} > <span aria-hidden className="pointer-events-none absolute start-2 flex size-4 items-center justify-center" > <MenuPrimitive.CheckboxItemIndicator keepMounted data-slot="dropdown-menu-checkbox-item-indicator" className={dropdownMenuIndicatorClassName} > <IconCheck className="text-current" /> </MenuPrimitive.CheckboxItemIndicator> </span> {children} </MenuPrimitive.CheckboxItem> ) } function DropdownMenuRadioGroup(props: MenuPrimitive.RadioGroup.Props) { return ( <DropdownMenuGroupContext.Provider value> <MenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} /> </DropdownMenuGroupContext.Provider> ) } type DropdownMenuRadioItemProps = MenuPrimitive.RadioItem.Props & { inset?: boolean } function DropdownMenuRadioItem({ className, children, inset, closeOnClick = false, onClick, ...props }: DropdownMenuRadioItemProps) { const choice = useChooseOnClick(closeOnClick, onClick) return ( <MenuPrimitive.RadioItem data-slot="dropdown-menu-radio-item" data-inset={inset ? "" : undefined} data-chosen={choice.chosen} closeOnClick={closeOnClick} onClick={choice.onClick} className={mergeClassName( cn(dropdownMenuItemVariants(), "ps-8"), className )} {...props} > <span aria-hidden className="pointer-events-none absolute start-2 flex size-4 items-center justify-center" > <MenuPrimitive.RadioItemIndicator keepMounted data-slot="dropdown-menu-radio-item-indicator" className={dropdownMenuIndicatorClassName} > <IconCircleFilled className="size-2 text-current" /> </MenuPrimitive.RadioItemIndicator> </span> {children} </MenuPrimitive.RadioItem> ) } function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) { return ( <MenuPrimitive.Separator data-slot="dropdown-menu-separator" className={mergeClassName("-mx-1 my-1 h-px bg-border", className)} {...props} /> ) } function DropdownMenuShortcut({ className, dir = "ltr", children, ...props }: React.ComponentProps<"kbd">) { return ( <kbd data-slot="dropdown-menu-shortcut" className={cn( "ms-auto shrink-0 ps-3 font-sans text-xs tracking-widest text-muted-foreground in-data-highlighted:text-accent-foreground/70 in-data-[variant=destructive]:text-destructive/70", className )} {...props} > <span dir={dir} className="[unicode-bidi:isolate]"> {children} </span> </kbd> ) } function DropdownMenuSub(props: MenuPrimitive.SubmenuRoot.Props) { return <MenuPrimitive.SubmenuRoot {...props} /> } type DropdownMenuSubTriggerProps = MenuPrimitive.SubmenuTrigger.Props & { inset?: boolean } function DropdownMenuSubTrigger({ className, children, inset, ...props }: DropdownMenuSubTriggerProps) { return ( <MenuPrimitive.SubmenuTrigger data-slot="dropdown-menu-sub-trigger" data-inset={inset ? "" : undefined} className={mergeClassName( cn( dropdownMenuItemVariants(), "data-popup-open:bg-accent data-popup-open:text-accent-foreground forced-colors:data-popup-open:outline-2 forced-colors:data-popup-open:-outline-offset-2 forced-colors:data-popup-open:outline-solid" ), className )} {...props} > {children} <IconChevronRight aria-hidden data-slot="dropdown-menu-sub-trigger-icon" className="ms-auto rtl:-scale-x-100" /> </MenuPrimitive.SubmenuTrigger> ) } function DropdownMenuSubContent({ sideOffset = 0, alignOffset = -4, ...props }: DropdownMenuContentProps) { return ( <DropdownMenuPopup slot="dropdown-menu-sub-content" sideOffset={sideOffset} alignOffset={alignOffset} {...props} /> ) } const createDropdownMenuHandle = MenuPrimitive.createHandle export { DropdownMenu, DropdownMenuTrigger, DropdownMenuPortal, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, createDropdownMenuHandle, dropdownMenuItemVariants, } export type { DropdownMenuProps, DropdownMenuContentProps, DropdownMenuLabelProps, DropdownMenuItemProps, DropdownMenuCheckboxItemProps, DropdownMenuRadioItemProps, DropdownMenuSubTriggerProps, }components/ui/kbd.tsx "use client" import * as React from "react" import { mergeProps } from "@base-ui/react/merge-props" import { useRender } from "@base-ui/react/use-render" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { useHeldKeys } from "@/hooks/use-held-keys" import { keyLabel, parseHotkey, spokenKey, useIsApple } from "@/lib/hotkey" function resolveKey(key: string, apple: boolean) { if (key === "mod") { return apple ? "meta" : "ctrl" } return key } const kbdVariants = cva( "inline-flex w-fit shrink-0 items-center justify-center gap-0.5 font-sans font-medium whitespace-nowrap tabular-nums transition-[translate,box-shadow,background-color,color] duration-100 ease-out-cubic select-none [unicode-bidi:isolate] motion-reduce:transition-colors [&_svg]:pointer-events-none [&_svg]:shrink-0", { variants: { variant: { keycap: "bg-background text-muted-foreground shadow-[0_1px_0_0_var(--color-border)] inset-ring-(length:--hairline) inset-ring-border in-data-[slot=button]:bg-current/10 in-data-[slot=button]:text-current in-data-[slot=button]:shadow-none in-data-[slot=button]:inset-ring-current/20 data-pressed:bg-muted data-pressed:text-foreground data-pressed:shadow-none motion-safe:data-pressed:translate-y-px dark:not-in-data-[slot=button]:bg-muted/60 dark:data-pressed:bg-muted forced-colors:border", flat: "bg-muted text-muted-foreground in-data-highlighted:bg-background in-data-[slot=button]:bg-current/10 in-data-[slot=button]:text-current data-pressed:bg-foreground/15 data-pressed:text-foreground dark:in-data-highlighted:bg-foreground/10", }, size: { sm: "h-4.5 min-w-4.5 rounded-[calc(var(--radius-sm)*0.6)] px-1 text-xs [&_svg:not([class*='size-'])]:size-2.5", default: "h-5 min-w-5 rounded-[calc(var(--radius-sm)*0.75)] px-1 text-xs [&_svg:not([class*='size-'])]:size-3", lg: "h-6 min-w-6 rounded-sm px-1.5 text-sm [&_svg:not([class*='size-'])]:size-3.5", }, }, defaultVariants: { variant: "keycap", size: "default", }, } ) type KbdVariant = NonNullable<VariantProps<typeof kbdVariants>["variant"]> type KbdSize = NonNullable<VariantProps<typeof kbdVariants>["size"]> type KbdGroupContextValue = { variant?: KbdVariant size?: KbdSize listen?: boolean } const KbdGroupContext = React.createContext<KbdGroupContextValue | null>(null) function KeyName({ keys, apple }: { keys: string[]; apple: boolean }) { const visible = keys.map((key) => keyLabel(key, apple)) const spoken = keys.map((key) => spokenKey(key, apple)).join(" ") const label = apple ? visible.join("") : visible.join("+") if (label === spoken) { return label } return ( <> <span aria-hidden="true">{label}</span> <span className="sr-only">{spoken}</span> </> ) } type KbdProps = useRender.ComponentProps<"kbd"> & { variant?: KbdVariant size?: KbdSize keys?: string listen?: boolean } function Kbd({ className, variant, size, keys, listen, render, children, ...props }: KbdProps) { const group = React.useContext(KbdGroupContext) const resolvedVariant = variant ?? group?.variant ?? "keycap" const resolvedSize = size ?? group?.size ?? "default" const listening = listen ?? group?.listen ?? false const apple = useIsApple() const pressedKeys = useHeldKeys(listening) const chord = keys ? (parseHotkey(keys)[0] ?? []) : null const own = chord ?? (typeof children === "string" && children.trim() ? parseHotkey(children.trim().replace(/\s+/g, "+"))[0] : null) const pressed = listening && own !== null && own.length > 0 && own.every((key) => pressedKeys.has(resolveKey(key, apple))) return useRender({ defaultTagName: "kbd", render, props: mergeProps<"kbd">( { className: cn( kbdVariants({ variant: resolvedVariant, size: resolvedSize }), className ), children: chord ? <KeyName keys={chord} apple={apple} /> : children, }, props, { "data-slot": "kbd", "data-variant": resolvedVariant, "data-size": resolvedSize, ...(pressed ? { "data-pressed": "" } : {}), } as React.ComponentProps<"kbd"> ), }) } type KbdGroupProps = useRender.ComponentProps<"kbd"> & { variant?: KbdVariant size?: KbdSize keys?: string listen?: boolean separator?: React.ReactNode } function KbdGroup({ className, variant, size, keys, listen, separator = "then", render, children, ...props }: KbdGroupProps) { const context = React.useMemo( () => ({ variant, size, listen }), [variant, size, listen] ) const chords = keys ? parseHotkey(keys) : null const content = chords ? chords.map((chord, index) => ( <React.Fragment key={index}> {index > 0 && ( <span data-slot="kbd-separator" className="px-0.5 font-sans text-xs text-muted-foreground" > {separator} </span> )} {chord.map((key, keyIndex) => ( <Kbd key={keyIndex} keys={key} /> ))} </React.Fragment> )) : children const element = useRender({ defaultTagName: "kbd", render, props: mergeProps<"kbd">( { className: cn( "inline-flex w-fit shrink-0 items-center gap-1 align-middle [unicode-bidi:isolate] rtl:flex-row-reverse", className ), children: content, }, props, { "data-slot": "kbd-group" } as React.ComponentProps<"kbd"> ), }) return ( <KbdGroupContext.Provider value={context}> {element} </KbdGroupContext.Provider> ) } export { Kbd, KbdGroup, kbdVariants, useHeldKeys } export type { KbdGroupProps, KbdProps }components/ui/spinner.tsx "use client" import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { useDelayedLoading, type DelayedLoadingOptions, } from "@/hooks/use-delayed-loading" const spinnerVariants = cva("shrink-0", { variants: { size: { sm: "size-3.5", default: "size-4", lg: "size-5", xl: "size-8", }, }, defaultVariants: { size: "default", }, }) type SpinnerVariant = "ticks" | "ring" type SpinnerProps = Omit<React.ComponentProps<"svg">, "children"> & VariantProps<typeof spinnerVariants> & { variant?: SpinnerVariant label?: string animated?: boolean loading?: boolean delay?: number minDuration?: number } const ticks = Array.from({ length: 8 }, (_, index) => index) function Spinner({ className, variant = "ticks", size = "default", label = "Loading", animated = true, loading, delay, minDuration, ...props }: SpinnerProps) { const delayed = useDelayedLoading(loading ?? false, { delay, minDuration }) const shown = loading === undefined || delayed if (!shown) { return null } const hidden = props["aria-hidden"] === true || props["aria-hidden"] === "true" return ( <svg viewBox="0 0 24 24" fill="none" role={hidden ? undefined : "status"} aria-label={hidden ? undefined : label} data-slot="spinner" data-variant={variant} className={cn( spinnerVariants({ size }), animated && (variant === "ticks" ? "motion-safe:animate-spinner-ticks" : "motion-safe:animate-spinner-rotate"), animated && "motion-reduce:animate-spinner-pulse", loading !== undefined && "transition-opacity duration-200 ease-out motion-reduce:transition-none starting:opacity-0", className )} {...props} > {variant === "ticks" ? ( ticks.map((index) => ( <line key={index} x1="12" y1="2.75" x2="12" y2="6.75" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" opacity={1 - index * 0.11} transform={`rotate(${-index * 45} 12 12)`} /> )) ) : ( <> <circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.5" opacity="0.2" /> <circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeDasharray="16 60" transform="rotate(-90 12 12)" className={cn(animated && "motion-safe:animate-spinner-dash")} /> </> )} </svg> ) } export { Spinner, spinnerVariants, useDelayedLoading } export type { DelayedLoadingOptions, SpinnerProps, SpinnerVariant }components/ui/tooltip.tsx "use client" import * as React from "react" import { DirectionProvider, useDirection, type TextDirection, } from "@base-ui/react/direction-provider" import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip" import { cn } from "cn" type ClassName<State> = string | ((state: State) => string | undefined) | undefined function mergeClassName<State>(base: string, className: ClassName<State>) { return typeof className === "function" ? (state: State) => cn(base, className(state)) : cn(base, className) } function readDirection(element: Element | null | undefined) { if (!element || !element.isConnected) { return undefined } return getComputedStyle(element).direction === "rtl" ? "rtl" : "ltr" } const TooltipProviderContext = React.createContext(false) const TooltipGroupContext = React.createContext<TooltipPrimitive.Handle<React.ReactNode> | null>(null) const TooltipTriggerContext = React.createContext<React.RefObject<Element | null> | null>(null) function TooltipProvider({ delay = 300, closeDelay = 0, timeout = 400, ...props }: TooltipPrimitive.Provider.Props) { return ( <TooltipProviderContext.Provider value={true}> <TooltipPrimitive.Provider delay={delay} closeDelay={closeDelay} timeout={timeout} {...props} /> </TooltipProviderContext.Provider> ) } function Tooltip<Payload>({ onOpenChange, children, ...props }: TooltipPrimitive.Root.Props<Payload>) { const hasProvider = React.useContext(TooltipProviderContext) const inherited = useDirection() const triggerRef = React.useRef<Element | null>(null) const [triggerDirection, setTriggerDirection] = React.useState<TextDirection>() const direction = triggerDirection === "rtl" ? "rtl" : inherited const root = ( <TooltipTriggerContext.Provider value={triggerRef}> <DirectionProvider direction={direction}> <TooltipPrimitive.Root onOpenChange={(open, eventDetails) => { onOpenChange?.(open, eventDetails) if (open && !eventDetails.isCanceled && eventDetails.trigger) { triggerRef.current = eventDetails.trigger setTriggerDirection(readDirection(eventDetails.trigger)) } }} {...props} > {children} </TooltipPrimitive.Root> </DirectionProvider> </TooltipTriggerContext.Provider> ) return hasProvider ? root : <TooltipProvider>{root}</TooltipProvider> } type TooltipTriggerProps<Payload> = Omit< TooltipPrimitive.Trigger.Props<Payload>, "content" > & { content?: React.ReactNode } function TooltipTrigger<Payload>({ ref, render, type, delay, handle, payload, content, ...props }: TooltipTriggerProps<Payload>) { const hasProvider = React.useContext(TooltipProviderContext) const triggerRef = React.useContext(TooltipTriggerContext) const groupHandle = React.useContext(TooltipGroupContext) const grouped = handle === undefined && groupHandle !== null const setRef = React.useCallback( (node: HTMLButtonElement | null) => { if (triggerRef && node) { triggerRef.current ??= node } if (typeof ref === "function") { ref(node) } else if (ref) { ref.current = node } }, [ref, triggerRef] ) return ( <TooltipPrimitive.Trigger ref={setRef} data-slot="tooltip-trigger" render={render} delay={delay ?? (hasProvider ? undefined : 300)} type={type ?? (render === undefined ? "button" : undefined)} handle={ grouped ? (groupHandle as unknown as TooltipPrimitive.Handle<Payload>) : handle } payload={grouped ? (content as Payload) : payload} {...props} /> ) } function TooltipPortal(props: TooltipPrimitive.Portal.Props) { return <TooltipPrimitive.Portal data-slot="tooltip-portal" {...props} /> } type TooltipContentProps = TooltipPrimitive.Popup.Props & Pick< TooltipPrimitive.Positioner.Props, | "side" | "align" | "sideOffset" | "alignOffset" | "anchor" | "arrowPadding" | "collisionPadding" | "collisionAvoidance" | "collisionBoundary" | "sticky" | "positionMethod" | "disableAnchorTracking" > & { portalProps?: Omit<TooltipPrimitive.Portal.Props, "children"> arrow?: boolean } function TooltipContent({ className, children, side = "top", align = "center", arrow = false, sideOffset = arrow ? 8 : 6, alignOffset = 0, anchor, arrowPadding = 8, collisionPadding = 8, collisionAvoidance, collisionBoundary, sticky, positionMethod, disableAnchorTracking, portalProps, dir, ...props }: TooltipContentProps) { const direction = useDirection() const triggerRef = React.useContext(TooltipTriggerContext) const setPositioner = React.useCallback( (positioner: HTMLDivElement | null) => { if (!positioner || dir !== undefined || direction === "rtl") { return } if (readDirection(triggerRef?.current) === "rtl") { positioner.setAttribute("dir", "rtl") } }, [dir, direction, triggerRef] ) return ( <TooltipPrimitive.Portal {...portalProps}> <TooltipPrimitive.Positioner ref={setPositioner} data-slot="tooltip-positioner" dir={dir ?? (direction === "rtl" ? "rtl" : undefined)} side={side} align={align} sideOffset={sideOffset} alignOffset={alignOffset} anchor={anchor} arrowPadding={arrowPadding} collisionPadding={collisionPadding} collisionAvoidance={collisionAvoidance} collisionBoundary={collisionBoundary} sticky={sticky} positionMethod={positionMethod} disableAnchorTracking={disableAnchorTracking} className="isolate z-50 h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) outline-none focus-visible:outline-hidden has-data-transitioning:duration-250 has-data-transitioning:ease-out-quint data-instant:transition-none motion-safe:has-data-transitioning:transition-[top,left,right,bottom] motion-reduce:transition-none" > <TooltipPrimitive.Popup data-slot="tooltip-content" className={mergeClassName( "relative h-(--popup-height,auto) w-(--popup-width,auto) max-w-[min(var(--container-2xs),var(--available-width))] origin-(--transform-origin) rounded-md bg-foreground text-xs/4 text-pretty wrap-anywhere text-background shadow-md/10 transition-[opacity,scale,width,height] duration-[150ms,150ms,250ms,250ms] ease-out-quint outline-none focus-visible:outline-hidden data-ending-style:opacity-0 data-ending-style:duration-100 data-instant:transition-none data-starting-style:opacity-0 motion-safe:data-starting-style:scale-96 motion-reduce:transition-opacity [&_[data-slot=kbd-separator]]:text-background/70 [&_kbd[data-slot=kbd][data-variant]]:rounded-[max(calc(var(--radius-sm)*0.5),calc(var(--radius-md)-0.25rem))] [&_kbd[data-slot=kbd][data-variant]]:bg-background/15 [&_kbd[data-slot=kbd][data-variant]]:text-background [&_kbd[data-slot=kbd][data-variant]]:shadow-none [&_kbd[data-slot=kbd][data-variant]]:inset-ring-background/20 [&_kbd[data-slot=kbd][data-variant]]:[direction:ltr]", className )} {...props} > <TooltipPrimitive.Viewport ref={freezeOutgoingWidth} data-slot="tooltip-viewport" className={viewport} > {children} </TooltipPrimitive.Viewport> {arrow && <TooltipArrow />} </TooltipPrimitive.Popup> </TooltipPrimitive.Positioner> </TooltipPrimitive.Portal> ) } const viewport = "relative size-full overflow-clip rounded-[inherit] [&>*]:flex [&>*]:items-center [&>*]:justify-between [&>*]:gap-2 [&>*]:px-2 [&>*]:py-1 [&>*:has(>[data-slot=kbd-group])]:pe-1 [&>*:has(>[data-slot=kbd])]:pe-1 [&>[data-current]]:w-(--popup-width) [&>[data-previous]]:top-0 [&>[data-previous]]:start-0 [&>[data-current]]:transition-[translate,opacity,filter] [&>[data-previous]]:transition-[translate,opacity,filter] [&>[data-current]]:duration-250 [&>[data-previous]]:duration-150 [&>[data-current]]:ease-out-quint [&>[data-previous]]:ease-out-quint [&>[data-current][data-starting-style]]:opacity-0 [&>[data-previous][data-ending-style]]:opacity-0 motion-safe:[&>[data-current][data-starting-style]]:blur-[1px] motion-safe:[&>[data-previous][data-ending-style]]:blur-[1px] motion-safe:data-[activation-direction~=right]:[&>[data-current][data-starting-style]]:translate-x-4 motion-safe:data-[activation-direction~=right]:[&>[data-previous][data-ending-style]]:-translate-x-4 motion-safe:data-[activation-direction~=left]:[&>[data-current][data-starting-style]]:-translate-x-4 motion-safe:data-[activation-direction~=left]:[&>[data-previous][data-ending-style]]:translate-x-4 motion-safe:data-[activation-direction~=down]:[&>[data-current][data-starting-style]]:translate-y-2 motion-safe:data-[activation-direction~=down]:[&>[data-previous][data-ending-style]]:-translate-y-2 motion-safe:data-[activation-direction~=up]:[&>[data-current][data-starting-style]]:-translate-y-2 motion-safe:data-[activation-direction~=up]:[&>[data-previous][data-ending-style]]:translate-y-2 motion-reduce:[&>*]:transition-opacity data-instant:[&>*]:transition-none" function freezeOutgoingWidth(viewport: HTMLDivElement | null) { if (!viewport || typeof MutationObserver === "undefined") { return } let currentWidth = 0 const sizes = typeof ResizeObserver === "undefined" ? null : new ResizeObserver((entries) => { for (const entry of entries) { if ((entry.target as HTMLElement).hasAttribute("data-current")) { currentWidth = entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width } } }) const sync = () => { for (const child of Array.from(viewport.children)) { const element = child as HTMLElement sizes?.observe(element) if (!element.hasAttribute("data-previous")) { element.style.removeProperty("width") delete element.dataset.frozen continue } const key = element.textContent ?? "" if (element.dataset.frozen !== key) { element.dataset.frozen = key const width = currentWidth || viewport.getBoundingClientRect().width element.style.width = `${width}px` } } } sync() const observer = new MutationObserver(sync) observer.observe(viewport, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ["data-current", "data-previous"], }) return () => { observer.disconnect() sizes?.disconnect() } } function TooltipArrow({ className, ...props }: TooltipPrimitive.Arrow.Props) { return ( <TooltipPrimitive.Arrow data-slot="tooltip-arrow" className={mergeClassName( "h-1.5 w-3 overflow-clip transition-[left,top] duration-250 ease-out-quint before:absolute before:bottom-0 before:left-1/2 before:size-[calc(var(--spacing)*1.5*sqrt(2))] before:-translate-x-1/2 before:translate-y-1/2 before:rotate-45 before:rounded-[1px] before:bg-foreground data-instant:transition-none data-[side=bottom]:-top-1.5 data-[side=inline-end]:-left-[9px] data-[side=inline-end]:-rotate-90 data-[side=inline-start]:-right-[9px] data-[side=inline-start]:rotate-90 data-[side=left]:-right-[9px] data-[side=left]:rotate-90 data-[side=right]:-left-[9px] data-[side=right]:-rotate-90 data-[side=top]:-bottom-1.5 data-[side=top]:rotate-180 motion-reduce:transition-none rtl:data-[side=inline-end]:right-[-9px] rtl:data-[side=inline-end]:left-auto rtl:data-[side=inline-end]:rotate-90 rtl:data-[side=inline-start]:right-auto rtl:data-[side=inline-start]:left-[-9px] rtl:data-[side=inline-start]:-rotate-90", className )} {...props} /> ) } type TooltipGroupProps = TooltipPrimitive.Provider.Props & Omit<TooltipContentProps, "children"> & { children?: React.ReactNode } function TooltipGroup({ delay, closeDelay = 100, timeout, children, ...contentProps }: TooltipGroupProps) { const [handle] = React.useState(() => TooltipPrimitive.createHandle<React.ReactNode>() ) return ( <TooltipProvider delay={delay} closeDelay={closeDelay} timeout={timeout}> <TooltipGroupContext.Provider value={handle}> {children} </TooltipGroupContext.Provider> <Tooltip handle={handle}> {({ payload }) => ( <TooltipContent {...contentProps}> {payload as React.ReactNode} </TooltipContent> )} </Tooltip> </TooltipProvider> ) } const createTooltipHandle = TooltipPrimitive.createHandle export { TooltipProvider, Tooltip, TooltipGroup, TooltipTrigger, TooltipPortal, TooltipContent, TooltipArrow, createTooltipHandle, } export type { TooltipContentProps, TooltipGroupProps, TooltipTriggerProps } - 4
Update the import paths to match your project setup.
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
└── VideoPlayerFullscreenButtonBar#
variant="bar" puts the controls under the picture on the page surface. They never hide or cover the video.
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.
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.
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.
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.
"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.
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.
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.
| 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.
const paused = useVideoPlayer((player) => player.paused)
const seek = useVideoPlayer((player) => player.seek)| 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 | – |