{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "expandable-slider",
  "title": "Expandable Slider",
  "description": "A composable slider that stays expanded on mobile and grows from an icon button on desktop hover, focus, or drag.",
  "files": [
    {
      "path": "registry/base/ui/expandable-slider.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\n// The surface is one pill: collapsed it is an icon button, expanded it is a\n// wider button that holds the track. Pill radii nest cleanly, so the trigger\n// fills the surface's content box instead of carrying its own box model.\n//\n// The glyph, rail, and handle keep the YouTube player's volume control sizing;\n// the box around them is tighter and the track is longer. The rim alone no\n// longer clears the glyph, so the track carries a small leading inset to hold\n// YouTube's 8px gap. The tail is wider than the lead only because our surface\n// ends in a rounded cap that a player bar does not have.\nconst SURFACE_HEIGHT = 36;\nconst SURFACE_BORDER = 1;\nconst TRIGGER_SIZE = SURFACE_HEIGHT - SURFACE_BORDER * 2;\nconst ICON_SIZE = 24;\nconst DEFAULT_TRACK_WIDTH = 72;\nconst RAIL_HEIGHT = 3;\nconst THUMB_SIZE = 12;\nconst TRACK_INSET = 4;\nconst TRACK_END_INSET = 12;\nconst PAGE_STEP_MULTIPLIER = 10;\n// Leaving the surface should not snap it shut: a grace period forgives a\n// pointer that clips the rounded cap on its way to the thumb. 300ms is the\n// hover-intent convention (Radix's close delay) and keeps the whole exit —\n// grace plus the 200ms collapse — inside half a second, so a control this\n// frequently used never reads as stuck open.\nconst DEFAULT_COLLAPSE_DELAY = 300;\n\ntype ExpandableSliderContextValue = {\n  value: number;\n  min: number;\n  max: number;\n  step: number;\n  /** Position of the value in [0, 1]. */\n  ratio: number;\n  disabled: boolean;\n  expanded: boolean;\n  dragging: boolean;\n  label: string;\n  valueText?: string;\n  trackWidth: number;\n  /** Attached by the rail; every measurement is taken from it. */\n  railRef: React.RefObject<HTMLDivElement | null>;\n  commitValue: (next: number) => void;\n  valueFromPointer: (clientX: number) => number | undefined;\n  stepBy: (steps: number) => void;\n  setDragging: (dragging: boolean) => void;\n};\n\nconst ExpandableSliderContext =\n  React.createContext<ExpandableSliderContextValue | null>(null);\n\nfunction useExpandableSliderContext(component: string) {\n  const context = React.useContext(ExpandableSliderContext);\n\n  if (!context) {\n    throw new Error(`${component} must be rendered inside <ExpandableSlider>.`);\n  }\n\n  return context;\n}\n\nexport type ExpandableSliderProps = Omit<\n  React.ComponentProps<\"div\">,\n  \"defaultValue\" | \"onChange\"\n> & {\n  /** Controlled value. */\n  value?: number;\n  /** Initial value for uncontrolled usage. */\n  defaultValue?: number;\n  /** Called after the slider requests a value change. */\n  onValueChange?: (value: number) => void;\n  min?: number;\n  max?: number;\n  step?: number;\n  /** Accessible name applied to the track. */\n  label: string;\n  /** Spoken value, e.g. `(value) => `${value}%``. */\n  formatValueText?: (value: number) => string;\n  /** Controlled desktop expansion; mobile viewports stay visually expanded. */\n  expanded?: boolean;\n  /** Called after the expanded state changes. */\n  onExpandedChange?: (expanded: boolean) => void;\n  /** Grace period in ms before collapsing once the pointer leaves; 0 collapses immediately. */\n  collapseDelay?: number;\n  /** Expanded track width in pixels. */\n  trackWidth?: number;\n  disabled?: boolean;\n};\n\n/**\n * Owns the value, the expansion state, and the pill surface. Compose the\n * revealed content from `ExpandableSliderTrigger` and `ExpandableSliderTrack`;\n * the track grows toward whichever side it is written on. Below the `sm`\n * breakpoint, the surface stays expanded so touch users can reach the slider\n * without a hover-only reveal step.\n */\nexport function ExpandableSlider({\n  value,\n  defaultValue = 0,\n  onValueChange,\n  min = 0,\n  max = 100,\n  step = 1,\n  label,\n  formatValueText,\n  expanded,\n  onExpandedChange,\n  collapseDelay = DEFAULT_COLLAPSE_DELAY,\n  trackWidth = DEFAULT_TRACK_WIDTH,\n  disabled = false,\n  className,\n  style,\n  children,\n  onPointerEnter,\n  onPointerLeave,\n  onFocus,\n  onBlur,\n  ...props\n}: ExpandableSliderProps) {\n  const [internalValue, setInternalValue] = React.useState(() =>\n    clampToStep(defaultValue, min, max, step),\n  );\n  const [hovered, setHovered] = React.useState(false);\n  const [focused, setFocused] = React.useState(false);\n  const [dragging, setDragging] = React.useState(false);\n  const railRef = React.useRef<HTMLDivElement | null>(null);\n  // The countdown is stored as the moment the pointer left rather than as a\n  // running timer, so a `collapseDelay` change re-arms it against the time\n  // already elapsed instead of waiting out the old value.\n  const [collapsePendingSince, setCollapsePendingSince] = React.useState<\n    number | null\n  >(null);\n\n  React.useEffect(() => {\n    if (collapsePendingSince === null) return;\n\n    const remaining = Math.max(\n      0,\n      collapseDelay - (Date.now() - collapsePendingSince),\n    );\n    const timeout = setTimeout(() => {\n      setCollapsePendingSince(null);\n      setHovered(false);\n    }, remaining);\n\n    return () => clearTimeout(timeout);\n  }, [collapseDelay, collapsePendingSince]);\n\n  const controlled = value !== undefined;\n  const currentValue = clampToStep(\n    controlled ? value : internalValue,\n    min,\n    max,\n    step,\n  );\n  const isExpanded =\n    expanded ?? (disabled ? false : hovered || focused || dragging);\n\n  const previousExpandedRef = React.useRef(isExpanded);\n\n  React.useEffect(() => {\n    if (previousExpandedRef.current === isExpanded) return;\n\n    previousExpandedRef.current = isExpanded;\n    onExpandedChange?.(isExpanded);\n  }, [isExpanded, onExpandedChange]);\n\n  const commitValue = React.useCallback(\n    (next: number) => {\n      if (!controlled) {\n        setInternalValue(next);\n      }\n\n      if (next !== currentValue) {\n        onValueChange?.(next);\n      }\n    },\n    [controlled, currentValue, onValueChange],\n  );\n\n  const valueFromPointer = React.useCallback(\n    (clientX: number) => {\n      const rail = railRef.current;\n\n      if (!rail) return undefined;\n\n      const rect = rail.getBoundingClientRect();\n      const usable = rect.width - THUMB_SIZE;\n\n      if (usable <= 0) return undefined;\n\n      const rawRatio = clamp(\n        (clientX - rect.left - THUMB_SIZE / 2) / usable,\n        0,\n        1,\n      );\n      // Pointer coordinates are always physical; in an RTL context the track\n      // runs right-to-left, so the ratio has to be mirrored to stay on the\n      // same side of the rail as the fill.\n      const ratio = isRtl(rail) ? 1 - rawRatio : rawRatio;\n\n      return clampToStep(min + ratio * (max - min), min, max, step);\n    },\n    [max, min, step],\n  );\n\n  const stepBy = React.useCallback(\n    (steps: number) => {\n      commitValue(clampToStep(currentValue + steps * step, min, max, step));\n    },\n    [commitValue, currentValue, max, min, step],\n  );\n\n  const context = React.useMemo<ExpandableSliderContextValue>(\n    () => ({\n      value: currentValue,\n      min,\n      max,\n      step,\n      ratio: max > min ? (currentValue - min) / (max - min) : 0,\n      disabled,\n      expanded: isExpanded,\n      dragging,\n      label,\n      valueText: formatValueText?.(currentValue),\n      trackWidth,\n      railRef,\n      commitValue,\n      valueFromPointer,\n      stepBy,\n      setDragging,\n    }),\n    [\n      commitValue,\n      currentValue,\n      disabled,\n      dragging,\n      formatValueText,\n      isExpanded,\n      label,\n      max,\n      min,\n      step,\n      stepBy,\n      trackWidth,\n      valueFromPointer,\n    ],\n  );\n\n  return (\n    <ExpandableSliderContext.Provider value={context}>\n      <div\n        data-slot=\"expandable-slider\"\n        data-expanded={isExpanded}\n        data-disabled={disabled || undefined}\n        className={cn(\n          // Mobile is expanded from first paint; desktop starts as a ghost icon\n          // button and materialises on interaction. Keeping this responsive\n          // branch in CSS avoids a matchMedia hydration flash.\n          \"group/expandable-slider inline-flex w-fit items-center rounded-full border border-border bg-background bg-clip-padding shadow-xs\",\n          \"transition-[background-color,border-color,box-shadow] duration-200 ease-[cubic-bezier(0,0,0.2,1)] motion-reduce:transition-none\",\n          \"sm:border-transparent sm:bg-transparent sm:shadow-none sm:data-[expanded=true]:border-border sm:data-[expanded=true]:bg-background sm:data-[expanded=true]:shadow-xs\",\n          \"dark:border-input dark:bg-input/30 sm:dark:border-transparent sm:dark:bg-transparent sm:dark:data-[expanded=true]:border-input sm:dark:data-[expanded=true]:bg-input/30\",\n          // Focus lives on the children, but the ring belongs to the surface:\n          // the panel clips its own content, so a child ring would be cut off.\n          \"has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-ring/50\",\n          disabled && \"opacity-50\",\n          className,\n        )}\n        style={{ height: SURFACE_HEIGHT, ...style }}\n        onPointerEnter={(event) => {\n          onPointerEnter?.(event);\n          setCollapsePendingSince(null);\n          setHovered(true);\n        }}\n        onPointerLeave={(event) => {\n          onPointerLeave?.(event);\n\n          if (collapseDelay <= 0) {\n            setCollapsePendingSince(null);\n            setHovered(false);\n            return;\n          }\n\n          setCollapsePendingSince(Date.now());\n        }}\n        onFocus={(event) => {\n          onFocus?.(event);\n          setFocused(true);\n        }}\n        onBlur={(event) => {\n          onBlur?.(event);\n\n          if (!event.currentTarget.contains(event.relatedTarget)) {\n            setFocused(false);\n          }\n        }}\n        {...props}\n      >\n        {children}\n      </div>\n    </ExpandableSliderContext.Provider>\n  );\n}\n\nexport type ExpandableSliderTriggerProps = React.ComponentProps<\"button\">;\n\n/** The always-visible control. Pass any icon as `children`. */\nexport function ExpandableSliderTrigger({\n  className,\n  style,\n  disabled,\n  type = \"button\",\n  ...props\n}: ExpandableSliderTriggerProps) {\n  const context = useExpandableSliderContext(\"ExpandableSliderTrigger\");\n  const isDisabled = disabled ?? context.disabled;\n\n  return (\n    <button\n      type={type}\n      disabled={isDisabled}\n      data-slot=\"expandable-slider-trigger\"\n      className={cn(\n        \"inline-flex shrink-0 items-center justify-center rounded-full text-foreground outline-none transition-colors\",\n        \"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-(--expandable-slider-icon-size)\",\n        isDisabled ? \"cursor-not-allowed\" : \"hover:bg-muted\",\n        className,\n      )}\n      style={\n        {\n          width: TRIGGER_SIZE,\n          height: TRIGGER_SIZE,\n          \"--expandable-slider-icon-size\": `${ICON_SIZE}px`,\n          ...style,\n        } as React.CSSProperties\n      }\n      {...props}\n    />\n  );\n}\n\nexport type ExpandableSliderTrackProps = React.ComponentProps<\"div\"> & {\n  /** Edge the track is anchored to while the panel grows. Use `end` when the track is written before the trigger. */\n  align?: \"start\" | \"end\";\n};\n\n/**\n * The revealed, full-height slider region — the pointer target is the whole\n * region, not the hairline rail, so a 3px bar never has to be hit. Defaults to\n * a rail with a range and a thumb; pass `children` to replace them.\n */\nexport function ExpandableSliderTrack({\n  align = \"start\",\n  className,\n  style,\n  children,\n  onPointerDown,\n  onPointerMove,\n  onPointerUp,\n  onPointerCancel,\n  onKeyDown,\n  ...props\n}: ExpandableSliderTrackProps) {\n  const context = useExpandableSliderContext(\"ExpandableSliderTrack\");\n  const {\n    commitValue,\n    disabled,\n    dragging,\n    label,\n    max,\n    min,\n    setDragging,\n    stepBy,\n    trackWidth,\n    value,\n    valueFromPointer,\n    valueText,\n  } = context;\n\n  const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (!dragging) return;\n\n    setDragging(false);\n\n    if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {\n      event.currentTarget.releasePointerCapture(event.pointerId);\n    }\n  };\n\n  const leadInset = align === \"start\" ? TRACK_INSET : TRACK_END_INSET;\n  const tailInset = align === \"start\" ? TRACK_END_INSET : TRACK_INSET;\n\n  return (\n    <div\n      data-slot=\"expandable-slider-panel\"\n      className={cn(\n        \"flex h-full w-(--expandable-slider-panel-width) items-center overflow-hidden transition-[width] duration-200 ease-[cubic-bezier(0,0,0.2,1)] will-change-[width] motion-reduce:transition-none\",\n        \"sm:w-0 sm:group-data-[expanded=true]/expandable-slider:w-(--expandable-slider-panel-width)\",\n        align === \"start\" ? \"justify-start\" : \"justify-end\",\n      )}\n      style={\n        {\n          \"--expandable-slider-panel-width\": `${trackWidth + leadInset + tailInset}px`,\n        } as React.CSSProperties\n      }\n    >\n      <div\n        role=\"slider\"\n        tabIndex={disabled ? -1 : 0}\n        aria-label={label}\n        aria-orientation=\"horizontal\"\n        aria-valuemin={min}\n        aria-valuemax={max}\n        aria-valuenow={value}\n        aria-valuetext={valueText}\n        aria-disabled={disabled || undefined}\n        data-slot=\"expandable-slider-track\"\n        className={cn(\n          \"flex h-full shrink-0 touch-none items-center outline-none\",\n          disabled ? \"cursor-not-allowed\" : \"cursor-pointer\",\n          className,\n        )}\n        style={{\n          width: trackWidth,\n          marginLeft: leadInset,\n          marginRight: tailInset,\n          ...style,\n        }}\n        onPointerDown={(event) => {\n          onPointerDown?.(event);\n\n          if (event.defaultPrevented || disabled || event.button !== 0) return;\n\n          // Keep the press from selecting surrounding text mid-drag.\n          event.preventDefault();\n          event.currentTarget.focus();\n          event.currentTarget.setPointerCapture?.(event.pointerId);\n          setDragging(true);\n\n          const next = valueFromPointer(event.clientX);\n\n          if (next !== undefined) {\n            commitValue(next);\n          }\n        }}\n        onPointerMove={(event) => {\n          onPointerMove?.(event);\n\n          if (event.defaultPrevented || !dragging) return;\n\n          const next = valueFromPointer(event.clientX);\n\n          if (next !== undefined) {\n            commitValue(next);\n          }\n        }}\n        onPointerUp={(event) => {\n          onPointerUp?.(event);\n          endDrag(event);\n        }}\n        onPointerCancel={(event) => {\n          onPointerCancel?.(event);\n          endDrag(event);\n        }}\n        onKeyDown={(event) => {\n          onKeyDown?.(event);\n\n          if (event.defaultPrevented || disabled) return;\n\n          const steps = KEY_STEPS[event.key];\n\n          if (steps !== undefined) {\n            event.preventDefault();\n            // Horizontal arrows follow the writing direction (ArrowLeft\n            // increases in RTL); the vertical pair and PageUp/Down keep their\n            // absolute meaning.\n            const directional =\n              HORIZONTAL_KEYS.includes(event.key) && isRtl(event.currentTarget)\n                ? -steps\n                : steps;\n\n            stepBy(directional);\n            return;\n          }\n\n          if (event.key === \"Home\") {\n            event.preventDefault();\n            commitValue(min);\n          } else if (event.key === \"End\") {\n            event.preventDefault();\n            commitValue(max);\n          }\n        }}\n        {...props}\n      >\n        {children ?? (\n          <ExpandableSliderRail>\n            <ExpandableSliderRange />\n            <ExpandableSliderThumb />\n          </ExpandableSliderRail>\n        )}\n      </div>\n    </div>\n  );\n}\n\nexport type ExpandableSliderRailProps = React.ComponentProps<\"div\">;\n\n/**\n * The hairline the value is measured against. It publishes the resolved\n * geometry as CSS variables so a replacement range or thumb can read it.\n */\nexport function ExpandableSliderRail({\n  className,\n  style,\n  ...props\n}: ExpandableSliderRailProps) {\n  const { railRef, ratio, trackWidth } = useExpandableSliderContext(\n    \"ExpandableSliderRail\",\n  );\n  const thumbOffset = ratio * Math.max(0, trackWidth - THUMB_SIZE);\n\n  return (\n    <div\n      ref={railRef}\n      aria-hidden=\"true\"\n      data-slot=\"expandable-slider-rail\"\n      className={cn(\"relative w-full rounded-full bg-primary/20\", className)}\n      style={\n        {\n          height: RAIL_HEIGHT,\n          \"--expandable-slider-thumb-size\": `${THUMB_SIZE}px`,\n          \"--expandable-slider-thumb-offset\": `${thumbOffset}px`,\n          // The fill stops at the thumb's centre, not its leading edge.\n          \"--expandable-slider-fill\": `${thumbOffset + THUMB_SIZE / 2}px`,\n          ...style,\n        } as React.CSSProperties\n      }\n      {...props}\n    />\n  );\n}\n\nexport type ExpandableSliderRangeProps = React.ComponentProps<\"div\">;\n\n/** The filled portion of the rail. */\nexport function ExpandableSliderRange({\n  className,\n  ...props\n}: ExpandableSliderRangeProps) {\n  return (\n    <div\n      data-slot=\"expandable-slider-range\"\n      className={cn(\n        \"absolute inset-y-0 start-0 w-(--expandable-slider-fill) rounded-full bg-primary\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport type ExpandableSliderThumbProps = React.ComponentProps<\"div\">;\n\n/** The handle. On desktop, it collapses with the surface to avoid clipping. */\nexport function ExpandableSliderThumb({\n  className,\n  ...props\n}: ExpandableSliderThumbProps) {\n  return (\n    <div\n      data-slot=\"expandable-slider-thumb\"\n      className={cn(\n        \"absolute top-1/2 start-(--expandable-slider-thumb-offset) size-(--expandable-slider-thumb-size) -translate-y-1/2 scale-100 rounded-full bg-primary shadow-sm\",\n        \"transition-transform duration-200 ease-[cubic-bezier(0,0,0.2,1)] motion-reduce:transition-none\",\n        \"sm:group-data-[expanded=false]/expandable-slider:scale-0\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nconst KEY_STEPS: Record<string, number | undefined> = {\n  ArrowRight: 1,\n  ArrowUp: 1,\n  ArrowLeft: -1,\n  ArrowDown: -1,\n  PageUp: PAGE_STEP_MULTIPLIER,\n  PageDown: -PAGE_STEP_MULTIPLIER,\n};\n\nconst HORIZONTAL_KEYS = [\"ArrowLeft\", \"ArrowRight\"];\n\n/** Reads the resolved writing direction, so `dir` anywhere up the tree counts. */\nfunction isRtl(element: Element) {\n  return window.getComputedStyle(element).direction === \"rtl\";\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(max, Math.max(min, value));\n}\n\nfunction clampToStep(value: number, min: number, max: number, step: number) {\n  const clamped = clamp(value, min, max);\n\n  if (!(step > 0)) {\n    return clamped;\n  }\n\n  const snapped = min + Math.round((clamped - min) / step) * step;\n\n  return roundToStepPrecision(clamp(snapped, min, max), step);\n}\n\nfunction roundToStepPrecision(value: number, step: number) {\n  const decimals = getDecimalCount(step);\n\n  return decimals === 0 ? Math.round(value) : Number(value.toFixed(decimals));\n}\n\nfunction getDecimalCount(step: number) {\n  const text = String(step);\n\n  if (text.includes(\"e\") || text.includes(\"E\")) {\n    return 0;\n  }\n\n  const separatorIndex = text.indexOf(\".\");\n\n  return separatorIndex === -1 ? 0 : text.length - separatorIndex - 1;\n}\n",
      "type": "registry:ui",
      "target": "components/ui/expandable-slider.tsx"
    }
  ],
  "meta": {
    "tags": [
      "slider",
      "range",
      "volume",
      "hover-reveal",
      "disclosure",
      "controlled",
      "composable"
    ],
    "effects": [
      "width-animation",
      "hover-reveal",
      "reduced-motion"
    ]
  },
  "categories": [
    "form"
  ],
  "type": "registry:ui"
}