{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "volume-control",
  "type": "registry:ui",
  "title": "Volume Control",
  "description": "Speaker waves follow the level; mute remembers, wheel nudges, popover tucks away.",
  "categories": [
    "sliders"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/volume-control.tsx",
      "content": "\"use client\";\nimport { Popover } from \"@base-ui/react/popover\";\nimport { Slider as BaseSlider } from \"@base-ui/react/slider\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState, type ComponentProps } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* ------------------------------------------------------------------ state */\n\nexport type UseVolumeOptions = {\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (value: number) => void;\n  muted?: boolean;\n  defaultMuted?: boolean;\n  onMutedChange?: (muted: boolean) => void;\n};\n\n/** Volume and mute as two separate facts, so unmuting returns to the level you left. */\nexport function useVolume({ value, defaultValue = 60, onValueChange, muted: mutedProp, defaultMuted = false, onMutedChange }: UseVolumeOptions = {}) {\n  const [volume, setVolume] = useControllableState({ value, defaultValue, onChange: onValueChange });\n  const [muted, setMuted] = useControllableState({ value: mutedProp, defaultValue: defaultMuted, onChange: onMutedChange });\n  // The last level you could hear, for when you unmute from a volume of 0.\n  const [audible, setAudible] = useState(defaultValue > 0 ? defaultValue : 60);\n\n  const set = useCallback(\n    (v: number) => {\n      const next = Math.round(Math.min(100, Math.max(0, v)));\n      if (next > 0) setAudible(next);\n      setVolume(next);\n      // Touching the level is a request to hear it.\n      if (muted) setMuted(false);\n    },\n    [muted, setMuted, setVolume],\n  );\n\n  /** Returns the level you will hear afterwards. */\n  const toggleMute = useCallback(() => {\n    if (muted || volume === 0) {\n      setMuted(false);\n      if (volume === 0) setVolume(audible);\n      return volume === 0 ? audible : volume;\n    }\n    setMuted(true);\n    return 0;\n  }, [audible, muted, setMuted, setVolume, volume]);\n\n  const level = muted ? 0 : volume;\n  return { volume, muted, level, setVolume: set, toggleMute, silent: level === 0 };\n}\n\n/** Wheel and trackpad scrolling over an element nudge the volume: 1% per 20px, 5% per mouse notch. */\nfunction useWheel(onStep: (steps: number) => void, enabled: boolean) {\n  const cb = useRef(onStep);\n  useEffect(() => {\n    cb.current = onStep;\n  });\n  return useCallback(\n    (el: HTMLElement | null) => {\n      if (!el || !enabled) return;\n      let acc = 0;\n      const onWheel = (e: WheelEvent) => {\n        e.preventDefault();\n        const unit = e.deltaMode === 1 ? 33 : 1;\n        // Up or right is louder, whichever axis the gesture mostly moved on.\n        acc += (Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : -e.deltaY) * unit;\n        const steps = Math.trunc(acc / 20);\n        if (steps) {\n          acc -= steps * 20;\n          cb.current(steps);\n        }\n      };\n      el.addEventListener(\"wheel\", onWheel, { passive: false });\n      return () => el.removeEventListener(\"wheel\", onWheel);\n    },\n    [enabled],\n  );\n}\n\n/* ------------------------------------------------------------------- icon */\n\nexport type VolumeIconProps = Omit<ComponentProps<\"svg\">, \"children\"> & { level: number; muted?: boolean; size?: number };\n\n/**\n * A speaker whose waves follow the level: none up to 33%, one to 66%, two above.\n * Silence (muted or 0) crosses it out. Each wave grows out of the cone.\n */\nexport function VolumeIcon({ level, muted = false, size = 16, className, ...rest }: VolumeIconProps) {\n  const reduce = useReducedMotion();\n  const silent = muted || level === 0;\n  const waves = silent ? 0 : level > 66 ? 2 : level > 33 ? 1 : 0;\n  // Every state animates the same properties so the server and client render the\n  // same markup; reduced motion only changes how long the non-opacity parts take.\n  const t = (i: number, on: boolean) => {\n    const base = { duration: on ? 0.26 : 0.16, ease: ease.out, delay: on ? i * 0.05 : (1 - i) * 0.04 }; // inner wave first in, outer first out\n    return reduce ? { default: { duration: 0 }, opacity: { duration: 0.12 } } : base;\n  };\n  const wave = (i: number) => {\n    const on = waves > i;\n    return {\n      initial: false as const,\n      animate: { opacity: on ? 1 : 0, pathLength: on ? 1 : 0.2, scale: on ? 1 : 0.6 },\n      transition: t(i, on),\n      style: { transformOrigin: \"6px 8px\", transformBox: \"view-box\" as const },\n    };\n  };\n  const cross = (i: number) => ({\n    initial: false as const,\n    animate: { opacity: silent ? 1 : 0, pathLength: silent ? 1 : 0 },\n    transition: reduce\n      ? { default: { duration: 0 }, opacity: { duration: 0.12 } }\n      : { duration: silent ? 0.22 : 0.12, ease: ease.out, delay: silent ? 0.06 + i * 0.06 : 0 },\n  });\n\n  return (\n    <svg\n      width={size}\n      height={size}\n      viewBox=\"0 0 16 16\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.4}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden\n      className={className}\n      {...rest}\n    >\n      <path d=\"M7.5 3.25 4.6 5.6H2.6v4.8h2l2.9 2.35z\" />\n      <motion.path d=\"M10 6.2a2.6 2.6 0 0 1 0 3.6\" {...wave(0)} />\n      <motion.path d=\"M12 4.4a5.2 5.2 0 0 1 0 7.2\" {...wave(1)} />\n      <motion.path d=\"M10.4 6.3l3.4 3.4\" {...cross(0)} />\n      <motion.path d=\"M13.8 6.3l-3.4 3.4\" {...cross(1)} />\n    </svg>\n  );\n}\n\n/* ------------------------------------------------------------------ track */\n\ntype TrackProps = {\n  level: number;\n  muted: boolean;\n  orientation: \"horizontal\" | \"vertical\";\n  disabled?: boolean;\n  label: string;\n  onChange: (v: number) => void;\n  onCommit?: (v: number) => void;\n  motion: \"glide\" | \"instant\";\n  setMotion: (m: \"glide\" | \"instant\") => void;\n  className?: string;\n};\n\n// The slider itself, shared by the inline and popover layouts. Mute and unmute\n// glide the fill down and back up; drags and keys are instant.\nfunction Track({ level, muted, orientation, disabled, label, onChange, onCommit, motion: mode, setMotion, className }: TrackProps) {\n  const vertical = orientation === \"vertical\";\n  return (\n    <BaseSlider.Root\n      value={level}\n      min={0}\n      max={100}\n      step={1}\n      largeStep={10}\n      orientation={orientation}\n      disabled={disabled}\n      onValueChange={(v, d) => {\n        setMotion(d.reason === \"track-press\" ? \"glide\" : \"instant\");\n        onChange(v as number);\n      }}\n      onValueCommitted={(v, d) => {\n        if (d.reason === \"drag\") setMotion(\"glide\");\n        onCommit?.(v as number);\n      }}\n      data-motion={mode}\n      className={cn(\"group/vol\", className)}\n    >\n      <BaseSlider.Control\n        className={cn(\n          \"group/control relative flex cursor-pointer touch-none select-none items-center group-data-disabled/vol:pointer-events-none\",\n          vertical ? \"h-full w-6 flex-col justify-center\" : \"h-6 w-full\",\n        )}\n      >\n        <BaseSlider.Track\n          className={cn(\"relative rounded-full bg-fg/10 transition-colors duration-150 group-hover/control:bg-fg/14\", vertical ? \"h-full w-1\" : \"h-1 w-full\")}\n        >\n          <div\n            aria-hidden\n            className={cn(\n              \"absolute rounded-full bg-fg\",\n              vertical ? \"inset-x-0 bottom-0\" : \"inset-y-0 left-0\",\n              vertical\n                ? \"group-data-[motion=glide]/vol:transition-[height] group-data-[motion=glide]/vol:duration-200 group-data-[motion=glide]/vol:ease-out-expo\"\n                : \"group-data-[motion=glide]/vol:transition-[width] group-data-[motion=glide]/vol:duration-200 group-data-[motion=glide]/vol:ease-out-expo\",\n            )}\n            style={vertical ? { height: `${level}%` } : { width: `${level}%` }}\n          />\n          <BaseSlider.Thumb\n            aria-label={label}\n            getAriaValueText={(_, v) => (muted ? \"Muted\" : `${v}%`)}\n            className={cn(\n              \"group/thumb size-3 outline-none before:absolute before:-inset-4 before:content-['']\",\n              vertical\n                ? \"group-data-[motion=glide]/vol:transition-[bottom] group-data-[motion=glide]/vol:duration-200 group-data-[motion=glide]/vol:ease-out-expo\"\n                : \"group-data-[motion=glide]/vol:transition-[inset-inline-start] group-data-[motion=glide]/vol:duration-200 group-data-[motion=glide]/vol:ease-out-expo\",\n            )}\n          >\n            <span\n              aria-hidden\n              className={cn(\n                \"absolute inset-0 rounded-full bg-fg shadow-[var(--shadow)]\",\n                \"outline-offset-2 group-has-focus-visible/thumb:outline-1 group-has-focus-visible/thumb:outline-solid group-has-focus-visible/thumb:outline-fg-3\",\n                // Resting small so the track reads first; grows under the pointer and swells on grab.\n                \"scale-75 transition-[scale,box-shadow] duration-150 ease-out-expo motion-reduce:transition-none\",\n                \"group-hover/control:scale-100 group-has-focus-visible/thumb:scale-100 group-data-dragging/vol:scale-125 group-data-dragging/vol:ring-4 group-data-dragging/vol:ring-fg/10\",\n              )}\n            />\n          </BaseSlider.Thumb>\n        </BaseSlider.Track>\n      </BaseSlider.Control>\n    </BaseSlider.Root>\n  );\n}\n\n/* ---------------------------------------------------------------- control */\n\nexport type VolumeControlProps = Omit<ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> &\n  UseVolumeOptions & {\n    /** Inline: icon beside a horizontal track. Popover: an icon button that opens a vertical track on hover or press. */\n    variant?: \"inline\" | \"popover\";\n    /** Print the level beside the inline track. */\n    showValue?: boolean;\n    /** Called once a drag or key press settles, and after mute changes. Persist here. */\n    onValueCommitted?: (value: number) => void;\n    /** What is being made louder, for the accessible names: “Volume”, “Call volume”. */\n    label?: string;\n    disabled?: boolean;\n    /** Where the popover portals to. Defaults to the body. */\n    container?: HTMLElement | React.RefObject<HTMLElement | null> | null;\n  };\n\nconst iconButton = cn(\n  \"relative grid size-8 shrink-0 place-items-center rounded-lg text-fg-2 outline-none\",\n  \"transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.92] active:duration-75\",\n  \"focus-visible:outline-1 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n  \"disabled:pointer-events-none disabled:opacity-50\",\n  // A 40px target around a 32px button.\n  \"before:absolute before:-inset-1 before:content-['']\",\n);\n\nexport function VolumeControl({\n  value,\n  defaultValue,\n  onValueChange,\n  muted: mutedProp,\n  defaultMuted,\n  onMutedChange,\n  onValueCommitted,\n  variant = \"inline\",\n  showValue = false,\n  label = \"Volume\",\n  disabled,\n  container,\n  className,\n  onKeyDown,\n  ...rest\n}: VolumeControlProps) {\n  const vol = useVolume({ value, defaultValue, onValueChange, muted: mutedProp, defaultMuted, onMutedChange });\n  const [mode, setMode] = useState<\"glide\" | \"instant\">(\"glide\");\n  // Several wheel events can land before a re-render; step from the latest level, not the rendered one.\n  const latest = useRef(vol.level);\n  useEffect(() => {\n    latest.current = vol.level;\n  }, [vol.level]);\n  const wheel = useWheel((steps) => {\n    const next = Math.min(100, Math.max(0, latest.current + steps));\n    latest.current = next;\n    setMode(\"glide\");\n    vol.setVolume(next);\n  }, !disabled);\n\n  const toggle = () => {\n    setMode(\"glide\");\n    // Toggle first: an optional call would skip evaluating its argument.\n    const heard = vol.toggleMute();\n    onValueCommitted?.(heard);\n  };\n\n  // M mutes from anywhere inside the control, as it does in most players.\n  const keys = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    onKeyDown?.(e);\n    if (e.defaultPrevented || disabled || e.metaKey || e.ctrlKey || e.altKey) return;\n    if (e.key === \"m\" || e.key === \"M\") {\n      e.preventDefault();\n      toggle();\n    }\n  };\n\n  const muteButton = (\n    <button\n      type=\"button\"\n      aria-label={label === \"Volume\" ? \"Mute\" : `Mute ${label.toLowerCase()}`}\n      aria-pressed={vol.silent}\n      aria-keyshortcuts=\"M\"\n      title={vol.silent ? \"Unmute (M)\" : \"Mute (M)\"}\n      disabled={disabled}\n      onClick={toggle}\n      className={iconButton}\n    >\n      <VolumeIcon level={vol.level} muted={vol.muted} />\n    </button>\n  );\n\n  const readout = (\n    <span aria-hidden className=\"w-[4ch] shrink-0 text-right text-[12px] text-fg-3 tabular\">\n      {vol.level}%\n    </span>\n  );\n\n  if (variant === \"popover\") {\n    return (\n      <div ref={wheel} onKeyDown={keys} data-variant=\"popover\" className={cn(\"inline-flex\", className)} {...rest}>\n        <Popover.Root>\n          <Popover.Trigger\n            openOnHover\n            delay={80}\n            closeDelay={160}\n            disabled={disabled}\n            aria-label={label}\n            className={cn(iconButton, \"data-popup-open:bg-hover data-popup-open:text-fg\")}\n          >\n            <VolumeIcon level={vol.level} muted={vol.muted} />\n          </Popover.Trigger>\n          <Popover.Portal container={container}>\n            <Popover.Positioner side=\"top\" sideOffset={8} className=\"z-(--z-popover)\">\n              <Popover.Popup\n                ref={wheel}\n                onKeyDown={keys}\n                className={cn(\n                  \"flex w-11 origin-(--transform-origin) flex-col items-center gap-1 rounded-xl border border-line-2 bg-raised pt-2.5 pb-1 shadow-pop outline-none\",\n                  \"transition-[opacity,scale,translate] duration-150 ease-out-expo\",\n                  \"data-starting-style:translate-y-1 data-starting-style:scale-95 data-starting-style:opacity-0\",\n                  \"data-ending-style:scale-95 data-ending-style:opacity-0 data-ending-style:duration-100 data-ending-style:ease-out\",\n                  \"motion-reduce:data-starting-style:translate-y-0 motion-reduce:data-starting-style:scale-100 motion-reduce:data-ending-style:scale-100\",\n                )}\n              >\n                <span aria-hidden className=\"text-[11px] leading-4 text-fg-2 tabular\">\n                  {vol.level}\n                </span>\n                <Track\n                  level={vol.level}\n                  muted={vol.muted}\n                  orientation=\"vertical\"\n                  disabled={disabled}\n                  label={label}\n                  onChange={vol.setVolume}\n                  onCommit={onValueCommitted}\n                  motion={mode}\n                  setMotion={setMode}\n                  className=\"h-28\"\n                />\n                {muteButton}\n              </Popover.Popup>\n            </Popover.Positioner>\n          </Popover.Portal>\n        </Popover.Root>\n      </div>\n    );\n  }\n\n  return (\n    <div\n      ref={wheel}\n      role=\"group\"\n      aria-label={label}\n      onKeyDown={keys}\n      data-variant=\"inline\"\n      data-muted={vol.silent || undefined}\n      className={cn(\"flex w-full min-w-0 items-center gap-1.5\", className)}\n      {...rest}\n    >\n      {muteButton}\n      <Track\n        level={vol.level}\n        muted={vol.muted}\n        orientation=\"horizontal\"\n        disabled={disabled}\n        label={label}\n        onChange={vol.setVolume}\n        onCommit={onValueCommitted}\n        motion={mode}\n        setMotion={setMode}\n        className=\"min-w-16 flex-1 px-1.5\"\n      />\n      {showValue && readout}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/volume-control"
}
