# Video player

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

Docs: https://hextaui.com/docs/video-player
Markdown: https://hextaui.com/docs/video-player.md

```tsx title="components/examples/video-player/demo.tsx"
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>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/video-player.json
```

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

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cn
```

Copy and paste the following code into your project.

```tsx title="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,
}
```

```tsx title="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 }
```

```tsx title="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,
}
```

```tsx title="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 }
```

```tsx title="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 }
```

```tsx title="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 }
```

Update the import paths to match your project setup.

## Usage

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

```tsx
<VideoPlayer>
  <VideoPlayerContent src="/intro.mp4" poster="/intro.jpg" />
  <VideoPlayerControls>
    <VideoPlayerSeekBar />
    <VideoPlayerPlayButton />
    <VideoPlayerVolume />
    <VideoPlayerTime />
    <VideoPlayerSpacer />
    <VideoPlayerFullscreenButton />
  </VideoPlayerControls>
</VideoPlayer>
```

## Composition

```text
VideoPlayer
├── VideoPlayerContent
└── VideoPlayerControls
    ├── VideoPlayerPlayButton
    ├── VideoPlayerSeekButton
    ├── VideoPlayerSeekBar
    ├── VideoPlayerTime
    ├── VideoPlayerSpacer
    ├── VideoPlayerVolume
    ├── VideoPlayerPlaybackRate
    ├── VideoPlayerCaptionsButton
    ├── VideoPlayerPictureInPictureButton
    └── VideoPlayerFullscreenButton
```

## Examples

### Bar

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

```tsx title="components/examples/video-player/bar.tsx"
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.

```tsx title="components/examples/video-player/minimal.tsx"
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.

```tsx title="components/examples/video-player/seek-offsets.tsx"
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.

```tsx title="components/examples/video-player/global-shortcuts.tsx"
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.

```tsx title="components/examples/video-player/captions.tsx"
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.

```tsx title="components/examples/video-player/custom-controls.tsx"
"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.

```tsx title="components/examples/video-player/error.tsx"
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.

```tsx title="components/examples/video-player/rtl.tsx"
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>
  )
}
```

## Keyboard

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 |
| --- | --- |
| `Space` `K` | 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. |
| `Home` `End` | Jumps to the start or the end. |

## Accessibility

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

## API reference

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

### VideoPlayer

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"overlay" \| "bar"` | `"overlay"` | overlay floats auto-hiding controls over the video. bar puts them below it. |
| `shortcuts` | `boolean` | `true` | Keyboard shortcuts while focus is in the player. |
| `globalShortcuts` | `boolean` | `false` | Also listen for shortcuts on the whole page. |
| `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. |

### VideoPlayerContent

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 | Description |
| --- | --- | --- | --- |
| `autoPlay` | `boolean` | `false` | Starts playback on mount, except under reduced motion. |
| `playsInline` | `boolean` | `true` |  |
| `preload` | `"none" \| "metadata" \| "auto"` | `"metadata"` |  |
| `doubleTapSeek` | `number \| false` | `10` | Seconds a double tap at either side jumps on touch screens. false turns it off. |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<video>` | Swap in another media element, such as an HLS video element. |

| Attribute | Description |
| --- | --- |
| `data-slot="video-player-content"` | Target the video in CSS. |

### VideoPlayerControls

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `tooltips` | `boolean` | `true` | Show each control’s label and shortcut on hover. |

| Attribute | Description |
| --- | --- |
| `data-slot="video-player-controls"` | Target the control bar in CSS. |
| `data-hidden` | Present while overlay controls are hidden. |

### VideoPlayerSeekBar

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 | Description |
| --- | --- | --- | --- |
| `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. |

### VideoPlayerPlayButton

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `playLabel` | `string` | `"Play"` |  |
| `pauseLabel` | `string` | `"Pause"` |  |
| `replayLabel` | `string` | `"Replay"` |  |
| `...props` | `ButtonProps` | – | Every Button prop, including variant and size. |

| Attribute | Description |
| --- | --- |
| `data-slot="video-player-play-button"` | Target the button in CSS. |
| `data-state` | "paused", "playing" or "ended". |

### VideoPlayerSeekButton

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `offset` | `number` | `10` | Seconds to jump. Negative values go back. |
| `label` | `string` | `"Forward 10 seconds"` |  |
| `...props` | `ButtonProps` | – | Every Button prop, including variant and size. |

| Attribute | Description |
| --- | --- |
| `data-slot="video-player-seek-button"` | Target the button in CSS. |
| `data-direction` | "backward" or "forward". |

### VideoPlayerVolume

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 | Description |
| --- | --- | --- | --- |
| `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". |

### VideoPlayerTime

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `type` | `"both" \| "elapsed" \| "remaining" \| "duration"` | `"both"` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="video-player-time"` | Target the time in CSS. |
| `data-type` | The current type. |

### VideoPlayerPlaybackRate

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `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. |

### VideoPlayerFullscreenButton

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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `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". |

### VideoPlayerCaptionsButton

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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `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. |

### VideoPlayerPictureInPictureButton

Renders nothing in browsers without picture in picture.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `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". |

### VideoPlayerSpacer

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

### useVideoPlayer

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

```tsx
const paused = useVideoPlayer((player) => player.paused)
const seek = useVideoPlayer((player) => player.seek)
```

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `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` | – |  |

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
