{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "slider",
  "type": "registry:ui",
  "title": "Slider",
  "description": "Thumb swells on grab, value rides above it, pressed track glides.",
  "categories": [
    "sliders"
  ],
  "dependencies": [
    "@base-ui/react"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/slider.tsx",
      "content": "\"use client\";\nimport { Slider as BaseSlider } from \"@base-ui/react/slider\";\nimport { useMemo, useState, type ComponentProps, type ReactNode } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\ntype Size = \"sm\" | \"md\" | \"lg\";\nexport type SliderMark = number | { value: number; label?: ReactNode };\n\nexport type SliderProps = Omit<\n  ComponentProps<typeof BaseSlider.Root>,\n  \"value\" | \"defaultValue\" | \"onValueChange\" | \"onValueCommitted\" | \"children\" | \"className\" | \"render\" | \"largeStep\" | \"orientation\" | \"thumbAlignment\"\n> & {\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (value: number) => void;\n  /** Fires once when a drag ends, a key is released or the track is pressed. Save here, not on every change. */\n  onValueCommitted?: (value: number) => void;\n  /** Visible label above the track. Without one, pass aria-label. */\n  label?: ReactNode;\n  /** Show the formatted value opposite the label. */\n  showValue?: boolean;\n  /** Step taken with Shift+Arrow and Page Up/Down. Defaults to 10 steps. */\n  largeStep?: number;\n  /** Dots on the track; give them a label to print it underneath (pressing a label jumps there). */\n  marks?: SliderMark[];\n  /** The value bubble over the thumb: while dragging or keyboard focused, always, or never. */\n  tooltip?: \"auto\" | \"always\" | \"never\";\n  /** Turns the number into the text shown in the readout, the bubble and to screen readers. */\n  formatValue?: (value: number) => string;\n  size?: Size;\n  className?: string;\n};\n\n// Track thickness, thumb diameter and control height per size. The control is\n// taller than the track so the whole band is a comfortable press target.\nconst sizes: Record<Size, { control: string; track: string; thumb: string }> = {\n  sm: { control: \"h-5\", track: \"h-1\", thumb: \"size-3\" },\n  md: { control: \"h-6\", track: \"h-1\", thumb: \"size-4\" },\n  lg: { control: \"h-7\", track: \"h-1.5\", thumb: \"size-5\" },\n};\n\nconst pct = (v: number, min: number, max: number) => ((v - min) / (max - min)) * 100;\n\nexport function Slider({\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  onValueCommitted,\n  label,\n  showValue = false,\n  min = 0,\n  max = 100,\n  step = 1,\n  largeStep,\n  marks,\n  tooltip = \"auto\",\n  formatValue,\n  format,\n  locale,\n  size = \"md\",\n  disabled,\n  className,\n  \"aria-label\": ariaLabel,\n  ...rest\n}: SliderProps) {\n  const [value, setValue] = useControllableState({\n    value: valueProp,\n    defaultValue: defaultValue ?? min,\n    onChange: onValueChange,\n  });\n  // \"glide\" lets the thumb travel to a pressed point or a pressed label; drags and\n  // keys switch it off so the thumb stays glued to the pointer and to each keystroke.\n  const [motion, setMotion] = useState<\"glide\" | \"instant\">(\"glide\");\n\n  const fmt = useMemo(() => {\n    if (formatValue) return formatValue;\n    const nf = new Intl.NumberFormat(locale, format);\n    return (v: number) => nf.format(v);\n  }, [formatValue, format, locale]);\n\n  const s = sizes[size];\n  const p = pct(value, min, max);\n  const list = (marks ?? []).map((m) => (typeof m === \"number\" ? { value: m, label: undefined } : m));\n  const labeled = list.some((m) => m.label != null);\n\n  return (\n    <BaseSlider.Root\n      value={value}\n      min={min}\n      max={max}\n      step={step}\n      largeStep={largeStep ?? step * 10}\n      format={format}\n      locale={locale}\n      disabled={disabled}\n      onValueChange={(next, details) => {\n        setMotion(details.reason === \"track-press\" ? \"glide\" : \"instant\");\n        setValue(next as number);\n      }}\n      onValueCommitted={(next, details) => {\n        if (details.reason === \"drag\") setMotion(\"glide\");\n        onValueCommitted?.(next as number);\n      }}\n      data-size={size}\n      data-motion={motion}\n      data-tooltip={tooltip}\n      className={cn(\"group/slider flex w-full flex-col gap-2 data-disabled:opacity-50\", className)}\n      {...rest}\n    >\n      {(label != null || showValue) && (\n        <div className=\"flex items-baseline justify-between gap-3\">\n          {label != null && <BaseSlider.Label className=\"min-w-0 truncate text-[12.5px] font-medium tracking-[-0.005em] text-fg\">{label}</BaseSlider.Label>}\n          {showValue && (\n            <span\n              aria-hidden\n              suppressHydrationWarning\n              className={cn(\n                \"ml-auto shrink-0 text-[12.5px] text-fg-2 tabular transition-opacity duration-150\",\n                // While the bubble is up the number lives by the thumb, where the eyes are.\n                tooltip === \"auto\" && \"group-data-dragging/slider:opacity-0 group-has-focus-visible/slider:opacity-0\",\n              )}\n            >\n              {fmt(value)}\n            </span>\n          )}\n        </div>\n      )}\n\n      <BaseSlider.Control\n        className={cn(\n          \"group/control relative flex touch-none select-none items-center\",\n          \"cursor-pointer group-data-disabled/slider:pointer-events-none\",\n          s.control,\n        )}\n      >\n        <BaseSlider.Track className={cn(\"relative w-full rounded-full bg-fg/10 transition-colors duration-150\", \"group-hover/control:bg-fg/14\", s.track)}>\n          {/* The fill is ours rather than Base UI's so it can glide with the thumb. */}\n          <div\n            aria-hidden\n            className={cn(\n              \"absolute inset-y-0 left-0 rounded-full bg-fg\",\n              \"group-data-[motion=glide]/slider:transition-[width] group-data-[motion=glide]/slider:duration-200 group-data-[motion=glide]/slider:ease-out-expo\",\n            )}\n            style={{ width: `${p}%` }}\n          />\n\n          {list.map((m) => {\n            const at = pct(m.value, min, max);\n            // A dot on the rounded cap reads as a smudge; the label still marks the end.\n            if (at <= 0 || at >= 100) return null;\n            return (\n              <span\n                key={m.value}\n                aria-hidden\n                data-passed={m.value <= value || undefined}\n                className={cn(\n                  \"absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full transition-colors duration-150\",\n                  \"bg-fg-3 data-passed:bg-frame/70\",\n                )}\n                style={{ left: `${at}%` }}\n              />\n            );\n          })}\n\n          <BaseSlider.Thumb\n            aria-label={ariaLabel}\n            getAriaValueText={(_, v) => fmt(v)}\n            className={cn(\n              \"group/thumb outline-none\",\n              // A 44px press target around a thumb that draws at 12–20px.\n              \"before:absolute before:-inset-3.5 before:content-['']\",\n              \"group-data-[motion=glide]/slider:transition-[inset-inline-start] group-data-[motion=glide]/slider:duration-200 group-data-[motion=glide]/slider:ease-out-expo\",\n              s.thumb,\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                \"transition-[scale,box-shadow] duration-150 ease-out-expo\",\n                // Hover hints, grab commits: the thumb swells and a soft ring marks the hold.\n                \"group-hover/control:scale-110 group-data-dragging/slider:scale-125 group-data-dragging/slider:ring-4 group-data-dragging/slider:ring-fg/10\",\n                \"motion-reduce:transition-none\",\n              )}\n            />\n            {tooltip !== \"never\" && (\n              <span\n                aria-hidden\n                suppressHydrationWarning\n                className={cn(\n                  \"pointer-events-none absolute bottom-full left-1/2 mb-2.5 -translate-x-1/2 origin-bottom whitespace-nowrap\",\n                  \"rounded-md bg-fg px-1.5 py-0.5 text-[11.5px] font-medium leading-4 text-frame tabular shadow-[var(--shadow)]\",\n                  \"transition-[opacity,scale,translate]\",\n                  tooltip === \"always\"\n                    ? \"opacity-100\"\n                    : cn(\n                        // Hidden: small, low and transparent, leaving quickly. Shown: grows up out of the thumb.\n                        \"translate-y-1 scale-90 opacity-0 duration-100 ease-out\",\n                        \"group-data-dragging/slider:translate-y-0 group-data-dragging/slider:scale-100 group-data-dragging/slider:opacity-100 group-data-dragging/slider:duration-150 group-data-dragging/slider:ease-out-expo\",\n                        \"group-has-focus-visible/thumb:translate-y-0 group-has-focus-visible/thumb:scale-100 group-has-focus-visible/thumb:opacity-100 group-has-focus-visible/thumb:duration-150 group-has-focus-visible/thumb:ease-out-expo\",\n                        \"motion-reduce:translate-y-0 motion-reduce:scale-100\",\n                      ),\n                )}\n              >\n                {fmt(value)}\n              </span>\n            )}\n          </BaseSlider.Thumb>\n        </BaseSlider.Track>\n      </BaseSlider.Control>\n\n      {labeled && (\n        // Pointer shortcut only: the thumb already covers every value from the keyboard.\n        <div aria-hidden className=\"relative -mt-0.5 h-4\">\n          {list.map((m) =>\n            m.label == null ? null : (\n              <button\n                key={m.value}\n                type=\"button\"\n                tabIndex={-1}\n                disabled={disabled}\n                data-current={m.value === value || undefined}\n                onClick={() => {\n                  setMotion(\"glide\");\n                  setValue(m.value);\n                  onValueCommitted?.(m.value);\n                }}\n                className={cn(\n                  \"absolute top-0 -translate-x-1/2 whitespace-nowrap rounded-sm px-1 text-[11px] leading-4 text-fg-3 tabular\",\n                  // The label draws 16px tall; the press target reaches 32px so it can be hit with a thumb.\n                  \"before:absolute before:-inset-x-1 before:-inset-y-2 before:content-['']\",\n                  \"transition-[color,scale] duration-150 hover:not-data-current:text-fg-2 active:scale-95 data-current:text-fg\",\n                  \"disabled:pointer-events-none\",\n                )}\n                style={{ left: `${pct(m.value, min, max)}%` }}\n              >\n                {m.label}\n              </button>\n            ),\n          )}\n        </div>\n      )}\n    </BaseSlider.Root>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/slider"
}
