{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "step-slider",
  "title": "Step Slider",
  "description": "A slider that snaps to a fixed set of values, with a dotted fill that sweeps on every step. Built on the Base UI Slider.",
  "dependencies": [
    "@base-ui/react",
    "cn"
  ],
  "files": [
    {
      "path": "registry/notra/step-slider/components/step-slider.tsx",
      "content": "\"use client\";\n\nimport { Slider as SliderPrimitive } from \"@base-ui/react/slider\";\nimport { cn } from \"cn\";\nimport { type CSSProperties, useEffect, useRef, useState } from \"react\";\n\nimport { nearestStepIndex, stepRatio } from \"../lib/step-slider\";\nimport type { StepSliderProps } from \"../types/step-slider\";\n\nconst STOP_UNDER_THUMB = 0.01;\n\nconst stopCenter = (ratio: number) => `calc(1rem + ${ratio} * (100% - 2rem))`;\n\nconst SWEEP_FROM = -60;\nconst SWEEP_TO = 160;\nconst SWEEP_KEYFRAMES: Keyframe[] = [\n  { maskPosition: `${SWEEP_FROM}% 0` },\n  { maskPosition: `${SWEEP_TO}% 0` },\n];\nconst SWEEP_TIMING: KeyframeAnimationOptions = {\n  duration: 900,\n  easing: \"ease-out\",\n};\nconst SWEEP_PAST_FILL = (100 - SWEEP_FROM) / (SWEEP_TO - SWEEP_FROM);\nconst SWEEP_BANDS = [0, 1] as const;\n\nconst defaultFormatLabel = (value: number) => value;\n\nexport const StepSlider = ({\n  steps,\n  value,\n  defaultValue,\n  onValueChange,\n  onValueCommitted,\n  formatLabel = defaultFormatLabel,\n  majorSteps,\n  hideLabels = false,\n  getValueText,\n  positionOf,\n  disabled,\n  name,\n  form,\n  className,\n  \"aria-label\": ariaLabel,\n  ...props\n}: StepSliderProps) => {\n  const [uncontrolledValue, setUncontrolledValue] = useState(\n    defaultValue ?? steps[0] ?? 0\n  );\n  const current = value ?? uncontrolledValue;\n  const index = nearestStepIndex(steps, current);\n  const lastIndex = Math.max(steps.length - 1, 0);\n  const position = Math.min(\n    Math.max(positionOf?.(current) ?? index, 0),\n    lastIndex\n  );\n  const ratio = lastIndex > 0 ? position / lastIndex : 0;\n\n  const sweepBands = useRef<(HTMLSpanElement | null)[]>([]);\n  const sweeps = useRef<(Animation | undefined)[]>([]);\n  const activeSweep = useRef(0);\n\n  useEffect(() => {\n    const active = sweeps.current[activeSweep.current];\n    const isRunning = active?.playState === \"running\";\n    const progress = active?.effect?.getComputedTiming().progress ?? 1;\n    if (isRunning && progress < SWEEP_PAST_FILL) {\n      return;\n    }\n\n    const next = isRunning ? 1 - activeSweep.current : activeSweep.current;\n    const band = sweepBands.current[next];\n    if (!band?.animate) {\n      return;\n    }\n    sweeps.current[next]?.cancel();\n    sweeps.current[next] = band.animate(SWEEP_KEYFRAMES, SWEEP_TIMING);\n    activeSweep.current = next;\n  }, [current]);\n\n  useEffect(\n    () => () => {\n      for (const sweep of sweeps.current) {\n        sweep?.cancel();\n      }\n    },\n    []\n  );\n\n  const majorStepSet = majorSteps ? new Set(majorSteps) : null;\n\n  const select = (nextIndex: number) => {\n    const next = steps[nextIndex];\n    if (next === undefined || next === current) {\n      return;\n    }\n    setUncontrolledValue(next);\n    onValueChange?.(next);\n  };\n\n  const commit = (nextIndex: number) => {\n    const next = steps[nextIndex];\n    if (next !== undefined) {\n      onValueCommitted?.(next);\n    }\n  };\n\n  return (\n    <SliderPrimitive.Root\n      className={cn(\"@container flex w-full flex-col gap-3\", className)}\n      data-slot=\"step-slider\"\n      disabled={disabled}\n      max={lastIndex}\n      min={0}\n      onValueChange={(nextIndex) => select(Math.round(nextIndex))}\n      onValueCommitted={(nextIndex) => commit(Math.round(nextIndex))}\n      step={1}\n      thumbAlignment=\"edge\"\n      value={position}\n      {...props}\n    >\n      {name ? (\n        <input\n          disabled={disabled}\n          form={form}\n          name={name}\n          type=\"hidden\"\n          value={steps[index] ?? current}\n        />\n      ) : null}\n      <div className=\"h-10 rounded-full bg-[#F1F1F2] p-1 shadow-[inset_0_0.0625rem_0.125rem_#1E1E1E0F] dark:bg-white/[0.06]\">\n        <SliderPrimitive.Control className=\"relative h-full cursor-pointer touch-none select-none data-disabled:cursor-not-allowed data-disabled:opacity-50\">\n          <SliderPrimitive.Track className=\"absolute inset-0 h-full\">\n            <SliderPrimitive.Indicator\n              className=\"box-content overflow-hidden rounded-full bg-linear-to-b from-violet-500 to-violet-600 pe-4 shadow-[inset_0_0.0625rem_0_#FFFFFF33] transition-[width] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none\"\n              data-slot=\"step-slider-indicator\"\n            >\n              <span className=\"absolute inset-0 bg-[radial-gradient(circle,#FFFFFF2E_0.75px,transparent_1.25px)] bg-size-[0.375rem_0.375rem] bg-center\" />\n              {SWEEP_BANDS.map((band) => (\n                <span\n                  className=\"absolute inset-0 bg-[radial-gradient(circle,#FFFFFFE6_0.9px,transparent_1.4px)] [mask-image:linear-gradient(90deg,transparent,#000_50%,transparent)] bg-size-[0.375rem_0.375rem] bg-center [mask-size:30%_100%] [mask-position:-60%_0] [mask-repeat:no-repeat] motion-reduce:hidden\"\n                  data-slot=\"step-slider-sweep\"\n                  key={band}\n                  ref={(element) => {\n                    sweepBands.current[band] = element;\n                  }}\n                />\n              ))}\n            </SliderPrimitive.Indicator>\n\n            {steps.map((step, stepIndex) => {\n              const stopPosition = stepRatio(stepIndex, steps.length);\n              if (Math.abs(stopPosition - ratio) < STOP_UNDER_THUMB) {\n                return null;\n              }\n\n              return (\n                <span\n                  aria-hidden=\"true\"\n                  className={cn(\n                    \"pointer-events-none absolute start-(--stop) top-1/2 size-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full transition-colors duration-300 rtl:translate-x-1/2\",\n                    stopPosition < ratio\n                      ? \"bg-white/70\"\n                      : \"bg-[#1E1E1E33] dark:bg-white/30\"\n                  )}\n                  key={step}\n                  style={\n                    { \"--stop\": stopCenter(stopPosition) } as CSSProperties\n                  }\n                />\n              );\n            })}\n          </SliderPrimitive.Track>\n\n          <SliderPrimitive.Thumb\n            aria-label={ariaLabel}\n            className=\"group/thumb size-8 rounded-full transition-[inset-inline-start] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] outline-none motion-reduce:transition-none\"\n            data-slot=\"step-slider-thumb\"\n            getAriaValueText={(_formatted, stepIndex) => {\n              const step = steps[stepIndex] ?? current;\n              return getValueText?.(step) ?? String(step);\n            }}\n          >\n            {/* The face scales on its own so Base UI keeps measuring the full thumb. */}\n            <span className=\"block size-full rounded-full bg-linear-to-b from-white to-[#F2F2F2] shadow-[0_0.0625rem_0.25rem_#28282840,0_0_0_0.0625rem_#1E1E1E0D] transition-[box-shadow] duration-200 ease-out group-has-focus-visible/thumb:ring-[0.1875rem] group-has-focus-visible/thumb:ring-violet-500/40\" />\n          </SliderPrimitive.Thumb>\n        </SliderPrimitive.Control>\n      </div>\n\n      {hideLabels ? null : (\n        <div className=\"relative mx-1 h-4 text-xs tracking-[-0.01em] text-[#1E1E1E80] tabular-nums dark:text-white/45\">\n          {steps.map((step, stepIndex) => {\n            const isMajor = majorStepSet?.has(step) ?? true;\n\n            return (\n              <button\n                className={cn(\n                  \"absolute start-(--stop) top-0 -translate-x-1/2 cursor-pointer transition-colors duration-100 hover:text-[#1E1E1E] disabled:pointer-events-none rtl:translate-x-1/2 dark:hover:text-white\",\n                  !isMajor && \"hidden @lg:block\",\n                  steps[stepIndex] === current &&\n                    \"font-medium text-[#1E1E1E] dark:text-white\"\n                )}\n                disabled={disabled}\n                key={step}\n                onClick={() => {\n                  if (steps[stepIndex] !== current) {\n                    select(stepIndex);\n                    commit(stepIndex);\n                  }\n                }}\n                style={\n                  {\n                    \"--stop\": stopCenter(stepRatio(stepIndex, steps.length)),\n                  } as CSSProperties\n                }\n                tabIndex={-1}\n                type=\"button\"\n              >\n                {formatLabel(step)}\n              </button>\n            );\n          })}\n        </div>\n      )}\n    </SliderPrimitive.Root>\n  );\n};\n",
      "type": "registry:component",
      "target": "components/step-slider/components/step-slider.tsx"
    },
    {
      "path": "registry/notra/step-slider/types/step-slider.ts",
      "content": "import type { Slider as SliderPrimitive } from \"@base-ui/react/slider\";\nimport type { ReactNode } from \"react\";\n\nexport interface StepSliderProps extends Omit<\n  SliderPrimitive.Root.Props<number>,\n  | \"children\"\n  | \"defaultValue\"\n  | \"form\"\n  | \"format\"\n  | \"largeStep\"\n  | \"max\"\n  | \"min\"\n  | \"name\"\n  | \"onValueChange\"\n  | \"onValueCommitted\"\n  | \"orientation\"\n  | \"step\"\n  | \"thumbAlignment\"\n  | \"value\"\n> {\n  /** The values the slider snaps to, in ascending order. */\n  steps: readonly number[];\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (value: number) => void;\n  /** Fires when a drag, key press or label click ends. */\n  onValueCommitted?: (value: number) => void;\n  /** Label under each stop. Defaults to the number itself. */\n  formatLabel?: (value: number) => ReactNode;\n  /** Steps whose label stays visible in a narrow container. All labels show when omitted. */\n  majorSteps?: readonly number[];\n  /** Hides the labels under the track. */\n  hideLabels?: boolean;\n  /**\n   * Fractional step index for a value, to place values between stops on the\n   * track, e.g. `2.5` sits halfway between the third and fourth stop. Snaps to\n   * the nearest stop when omitted.\n   */\n  positionOf?: (value: number) => number;\n  /** Submits the selected step value (not its index) under this name. */\n  name?: string;\n  /** Id of the form the value belongs to, when the slider sits outside it. */\n  form?: string;\n  /** Accessible name of the thumb. */\n  \"aria-label\"?: string;\n  /** Screen reader text for a value, e.g. `\"10 prompts\"`. */\n  getValueText?: (value: number) => string;\n}\n",
      "type": "registry:lib",
      "target": "components/step-slider/types/step-slider.ts"
    },
    {
      "path": "registry/notra/step-slider/lib/step-slider.ts",
      "content": "export const nearestStepIndex = (steps: readonly number[], value: number) => {\n  let nearest = 0;\n  for (const [index, step] of steps.entries()) {\n    if (Math.abs(step - value) < Math.abs((steps[nearest] ?? 0) - value)) {\n      nearest = index;\n    }\n  }\n  return nearest;\n};\n\nexport const stepRatio = (index: number, count: number) =>\n  count > 1 ? index / (count - 1) : 0;\n",
      "type": "registry:lib",
      "target": "components/step-slider/lib/step-slider.ts"
    }
  ],
  "type": "registry:component"
}