{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "slider",
  "type": "registry:ui",
  "title": "Slider",
  "description": "Pick a value or a range on a track that follows your finger.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/slider/slider.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type FocusEvent,\n  type KeyboardEvent,\n  type PointerEvent,\n  type ReactNode,\n} from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n  type MotionStyle,\n  type MotionValue,\n  type Variants,\n} from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport type SliderValue = number | [number, number];\nexport interface SliderMark {\n  value: number;\n  label?: string;\n}\n\nexport interface SliderProps<T extends SliderValue = number> {\n  /** Visible label. Range thumbs are named from it unless `thumbLabels` says otherwise. */\n  label: string;\n  /** A number for one thumb, a pair for a range. */\n  value?: T;\n  defaultValue?: T;\n  onValueChange?: (value: T) => void;\n  /** Runs once a drag is released or a key changes the value, for work too heavy for every step. */\n  onValueCommit?: (value: T) => void;\n  min?: number;\n  max?: number;\n  step?: number;\n  /** PageUp, PageDown, and Shift with an arrow move this far. Defaults to a tenth of the range. */\n  largeStep?: number;\n  /** Ticks on the track. A number draws a tick; a mark with a label also prints it under the track, and clicking it moves there. */\n  marks?: (number | SliderMark)[];\n  /** The closest two range thumbs may sit, in steps. */\n  minStepsBetweenThumbs?: number;\n  /** Formats the readout, the bubble, and the spoken value. */\n  format?: (value: number) => string;\n  /** Show the value beside the label. The bubble over the thumb still appears while dragging or focused. */\n  showValue?: boolean;\n  /** Accessible names for the two range thumbs. */\n  thumbLabels?: [string, string];\n  /** Content beside the track, such as an icon or a mute button. */\n  start?: ReactNode;\n  end?: ReactNode;\n  /** Submits the value with a form, one hidden input per thumb. */\n  name?: string;\n  disabled?: boolean;\n  className?: string;\n}\n\ntype Part = { key: string; digit: number } | { key: string; text: string };\ntype Drag = {\n  pointer: number;\n  index: number | null;\n  grab: number;\n  x: number;\n  raw: number;\n  samples: { t: number; p: number }[];\n};\n\nconst { spring, duration, blur } = motionTokens;\nconst enterEase = [...motionTokens.ease.enter] as [number, number, number, number];\nconst exitEase = [...motionTokens.ease.standard] as [number, number, number, number];\nconst DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];\n/** A digit column is a wheel whose window reaches a little past the line box and feathers out, so a turning digit fades at the edge. */\nconst column =\n  \"relative inline-block overflow-x-visible overflow-y-clip [--feather:.2em] my-[calc(var(--feather)*-1)] py-(--feather) [mask-image:linear-gradient(to_bottom,transparent,#000_calc(var(--feather)*1.5),#000_calc(100%_-_var(--feather)*1.5),transparent)]\";\n/** Ticks inside the fill are the \"on\" set, cut exactly at the thumb. */\nconst tick = \"absolute top-1/2 -mt-0.5 -ml-0.5 size-1 rounded-full\";\n/** Motion drops velocity on time-defined springs, so anything a gesture hands off to runs the same spring written as stiffness and damping.\n *  Positions are percentages of the track, so the rest thresholds are set far below a pixel. */\nconst physical = ({ visualDuration, bounce }: { visualDuration: number; bounce: number }) => {\n  const root = (2 * Math.PI) / (visualDuration * 1.2);\n  return {\n    type: \"spring\" as const,\n    stiffness: root * root,\n    damping: 2 * (1 - bounce) * root,\n    restDelta: 0.002,\n    restSpeed: 0.02,\n  };\n};\nconst thumbSpring = physical(spring.snappy);\nconst kickSpring = physical(spring.morph);\n/** Pixels of rubber-band travel past a limit, the velocity of a key press at a limit, and the pixel size of a step that release momentum may choose. */\nconst STRETCH = 9,\n  BUMP_PX = 150,\n  SNAP_PX = 12;\n/** Rubber-band resistance, as in overscroll: travel past a limit gives less and less, and never more than `limit` pixels. */\nconst rubber = (distance: number, limit = STRETCH) =>\n  Math.sign(distance) * (1 - 1 / ((Math.abs(distance) * 0.55) / limit + 1)) * limit;\n/** Where a release is heading, from a scroll-like deceleration (rate .99), in the same units as the velocity. */\nconst project = (velocity: number) => velocity * 0.099;\nconst decimalsOf = (value: number) => (String(value).split(\".\")[1] ?? \"\").length;\nconst clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));\nconst isDigit = (char: string) => char >= \"0\" && char <= \"9\";\n\n/** Digits are keyed by place value and the text around them by side, so $950 → $1,000 opens a column and a comma while the rest roll. */\nfunction partsOf(text: string): Part[] {\n  const chars = Array.from(text);\n  const first = chars.findIndex(isDigit);\n  const last = chars.length - 1 - [...chars].reverse().findIndex(isDigit);\n  let place = chars.filter(isDigit).length;\n  // Text between two digit places (\"30 requests / 10 s\") can repeat a character; its offset in that run keeps keys unique.\n  let run = 0;\n  return chars.map((char, index): Part => {\n    if (first < 0 || index < first) return { key: `p${index}${char}`, text: char };\n    if (index > last) return { key: `s${chars.length - index}${char}`, text: char };\n    if (isDigit(char)) {\n      run = 0;\n      return { key: `d${--place}`, digit: Number(char) };\n    }\n    return { key: `g${place}.${run++}${char}`, text: char };\n  });\n}\n\n/* A new column opens its width while it rises in the direction of change; a leaving one closes on a spring that never passes zero. */\nconst slot: Variants = {\n  enter: (direction: number) => ({\n    width: 0,\n    scale: 0.6,\n    opacity: 0,\n    y: `${direction * 0.3}em`,\n    filter: `blur(${blur.soft}px)`,\n  }),\n  center: {\n    width: \"auto\",\n    scale: 1,\n    opacity: 1,\n    y: 0,\n    filter: \"blur(0px)\",\n    transitionEnd: { filter: \"none\" },\n    transition: {\n      width: spring.morph,\n      scale: spring.morph,\n      opacity: spring.morph,\n      y: spring.snappy,\n      filter: { duration: duration.standard, ease: enterEase },\n    },\n  },\n  exit: (direction: number) => ({\n    width: 0,\n    scale: 0.6,\n    opacity: 0,\n    y: `${direction * -0.3}em`,\n    filter: `blur(${blur.subtle}px)`,\n    transition: {\n      width: spring.smooth,\n      scale: spring.smooth,\n      y: { duration: duration.fast, ease: exitEase },\n      opacity: { duration: duration.instant },\n      filter: { duration: duration.instant },\n    },\n  }),\n};\n/* Reduced motion keeps a short fade and no travel. Its resting state matches `slot`, so either branch hydrates the same markup. */\nconst still: Variants = {\n  enter: { width: \"auto\", scale: 1, opacity: 0, y: 0, filter: \"none\" },\n  center: { width: \"auto\", scale: 1, opacity: 1, y: 0, filter: \"none\", transition: { duration: duration.instant } },\n  exit: { width: 0, opacity: 0, transition: { duration: 0 } },\n};\n\n/** One digit on the wheel. Its distance from the wheel position sets where it sits, how clear it is, and whether it shows. */\nfunction Glyph({ position, digit }: { position: MotionValue<number>; digit: number }) {\n  const offset = (current: number) => ((((digit - current) % 10) + 15) % 10) - 5;\n  const y = useTransform(position, (current) => `${offset(current) * 1.05}em`);\n  const opacity = useTransform(position, (current) => Math.max(0, 1 - Math.abs(offset(current)) ** 1.5 * 1.1));\n  const visibility = useTransform(position, (current) => (Math.abs(offset(current)) >= 1 ? \"hidden\" : \"visible\"));\n  const filter = useTransform(position, (current) => {\n    const distance = Math.abs(offset(current));\n    return distance < 0.02 || distance >= 1 ? \"none\" : `blur(${(distance * blur.soft * 0.75).toFixed(2)}px)`;\n  });\n  return (\n    <motion.span\n      className=\"absolute inset-x-0 inset-y-(--feather) text-center\"\n      style={{ y, opacity, filter, visibility }}\n    >\n      {digit}\n    </motion.span>\n  );\n}\n\n/** An odometer wheel. It turns the way the value moved, wraps 9 → 0, and retargets mid spin while a drag keeps it busy. */\nfunction Wheel({ digit, direction }: { digit: number; direction: number }) {\n  const reduced = useReducedMotion();\n  const position = useMotionValue(digit);\n  const wheel = useRef({ digit, target: digit });\n  useLayoutEffect(() => {\n    const state = wheel.current;\n    if (state.digit === digit) return;\n    state.target += direction > 0 ? (digit - state.digit + 10) % 10 : -((state.digit - digit + 10) % 10);\n    state.digit = digit;\n    if (reduced) position.jump(state.target);\n    else animate(position, state.target, thumbSpring);\n  }, [digit, direction, position, reduced]);\n  return (\n    <>\n      <span className=\"invisible\">0</span>\n      {DIGITS.map((item) => (\n        <Glyph key={item} position={position} digit={item} />\n      ))}\n    </>\n  );\n}\n\n/** A formatted value whose digits roll in the direction it moved and whose width follows new columns on a spring. */\nfunction RollingNumber({ value, text }: { value: number; text: string }) {\n  const reduced = useReducedMotion();\n  const [trail, setTrail] = useState({ value, direction: 1 });\n  if (trail.value !== value) setTrail({ value, direction: value > trail.value ? 1 : -1 });\n  const direction = trail.value === value ? trail.direction : value > trail.value ? 1 : -1;\n  return (\n    <span className=\"inline-flex items-center whitespace-nowrap tabular-nums\">\n      <AnimatePresence initial={false} custom={direction}>\n        {partsOf(text).map((part) => (\n          <motion.span\n            key={part.key}\n            className={\"digit\" in part ? column : \"inline-block overflow-x-clip whitespace-pre\"}\n            custom={direction}\n            variants={reduced ? still : slot}\n            initial=\"enter\"\n            animate=\"center\"\n            exit=\"exit\"\n          >\n            {\"digit\" in part ? <Wheel digit={part.digit} direction={direction} /> : part.text}\n          </motion.span>\n        ))}\n      </AnimatePresence>\n    </span>\n  );\n}\n\nconst bubbleIn = { opacity: 0, scale: 0.6, y: 6, filter: `blur(${blur.subtle}px)` };\nconst bubbleRest = { opacity: 1, scale: 1, y: 0, filter: \"blur(0px)\", transitionEnd: { filter: \"none\" } };\nconst bubbleOut = {\n  opacity: 0,\n  scale: 0.8,\n  y: 4,\n  filter: `blur(${blur.subtle}px)`,\n  transition: { duration: duration.instant, ease: exitEase },\n};\nconst bubbleEnter = {\n  ...spring.snappy,\n  opacity: { duration: duration.fast, ease: enterEase },\n  filter: { duration: duration.fast, ease: enterEase },\n};\n\ninterface ThumbProps {\n  quiet: boolean;\n  index: number;\n  shown: MotionValue<number>;\n  value: number;\n  text: string;\n  low: number;\n  high: number;\n  ariaLabel?: string;\n  labelledBy?: string;\n  active: boolean;\n  lifted: boolean;\n  bubble: boolean;\n  disabled?: boolean;\n  onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;\n  onFocus: (event: FocusEvent<HTMLDivElement>) => void;\n  onBlur: () => void;\n}\n\n/** The thumb rides a layer as wide as the track, so a percentage transform places it with no measuring, on the server too. */\nfunction Thumb({\n  quiet,\n  index,\n  shown,\n  value,\n  text,\n  low,\n  high,\n  ariaLabel,\n  labelledBy,\n  active,\n  lifted,\n  bubble,\n  disabled,\n  onKeyDown,\n  onFocus,\n  onBlur,\n}: ThumbProps) {\n  const reduced = useReducedMotion();\n  const x = useTransform(shown, (current) => `${current - 100}%`);\n  return (\n    <motion.div className=\"absolute inset-0\" style={{ x, zIndex: active ? 2 : 1 }}>\n      <motion.div\n        role=\"slider\"\n        data-thumb=\"\"\n        data-index={index}\n        data-active={lifted || undefined}\n        data-quiet={quiet || undefined}\n        className=\"absolute top-1/2 right-0 box-border size-(--slider-thumb) cursor-grab rounded-full border-0 bg-control-thumb shadow-thumb transition-shadow duration-fast ease-standard [translate:50%_-50%] pointer-events-auto group-data-dragging/slider:cursor-grabbing group-data-disabled/slider:cursor-not-allowed motion-reduce:transition-none after:absolute after:-inset-[11px] after:rounded-[inherit] after:content-['']\"\n        tabIndex={disabled ? -1 : 0}\n        aria-label={ariaLabel}\n        aria-labelledby={ariaLabel ? undefined : labelledBy}\n        aria-valuemin={low}\n        aria-valuemax={high}\n        aria-valuenow={value}\n        aria-valuetext={text}\n        aria-orientation=\"horizontal\"\n        aria-disabled={disabled || undefined}\n        initial={false}\n        animate={{ scale: lifted ? 1.16 : 1 }}\n        transition={reduced ? { duration: 0 } : spring.snappy}\n        onKeyDown={onKeyDown}\n        onFocus={onFocus}\n        onBlur={onBlur}\n      />\n      {/* The bubble centers on the thumb until it would pass the component's edge, then holds there: --at is the thumb position in percent of\n        the track and cqw measures the track, so the clamp needs no measuring. It floats higher on touch so a fingertip never hides it. */}\n      <motion.span\n        className=\"pointer-events-none absolute right-0 bottom-[calc(50%_+_var(--slider-thumb)/2_+_8px)] flex [--edge:calc(var(--slider-thumb)/2)] [--x:calc(var(--at,0)*1cqw)] [translate:clamp(calc(100%_-_var(--edge)_-_var(--x)),50%,calc(100cqw_+_var(--edge)_-_var(--x)))_0] pointer-coarse:bottom-[calc(50%_+_var(--slider-thumb)/2_+_18px)]\"\n        style={{ \"--at\": shown } as MotionStyle}\n      >\n        <AnimatePresence>\n          {bubble && (\n            <motion.span\n              key=\"bubble\"\n              className=\"inline-flex min-h-7 origin-bottom items-center rounded-full border border-[color-mix(in_oklab,var(--page)_14%,var(--ink))] bg-ink px-3 text-sm/[inherit] font-medium whitespace-nowrap text-page shadow-raised\"\n              aria-hidden=\"true\"\n              initial={reduced ? { opacity: 0 } : bubbleIn}\n              animate={bubbleRest}\n              exit={reduced ? { opacity: 0, transition: { duration: duration.instant } } : bubbleOut}\n              transition={reduced ? { duration: duration.instant } : bubbleEnter}\n            >\n              <RollingNumber value={value} text={text} />\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </motion.span>\n    </motion.div>\n  );\n}\n\n/**\n * A value or a range on a track. The thumb follows the pointer 1:1, rubber-bands past its limits, and settles on the step grid with\n * the velocity of the release; pressing the track springs the nearest thumb there. A bubble with rolling digits rides the thumb while\n * it is dragged or keyboard focused. Arrows, Shift+arrows, PageUp, PageDown, Home, and End work as in a native range input.\n * Set `--slider-thumb` and `--slider-track` on the root (or through `className`) to resize; the track is inset by half a thumb, so a\n * thumb at either limit stays inside the box.\n */\nexport function Slider<T extends SliderValue = number>({\n  label,\n  value,\n  defaultValue,\n  onValueChange,\n  onValueCommit,\n  min = 0,\n  max = 100,\n  step: stepProp = 1,\n  largeStep,\n  marks,\n  minStepsBetweenThumbs = 0,\n  format,\n  showValue = true,\n  thumbLabels,\n  start,\n  end,\n  name,\n  disabled,\n  className,\n}: SliderProps<T>) {\n  const reduced = useReducedMotion();\n  const labelId = useId();\n  const step = stepProp > 0 ? stepProp : 1;\n  const span = max - min || 1;\n  const isRange = Array.isArray(value ?? defaultValue);\n  const toArray = (input: SliderValue | undefined) =>\n    input === undefined ? [min, max].slice(0, isRange ? 2 : 1) : Array.isArray(input) ? [input[0], input[1]] : [input];\n  const [internal, setInternal] = useState(() => toArray(defaultValue));\n  const values = value === undefined ? internal : toArray(value);\n  const decimals = Math.max(decimalsOf(step), decimalsOf(min));\n  const formatValue =\n    format ??\n    ((input: number) =>\n      input.toLocaleString(\"en-US\", { minimumFractionDigits: decimals, maximumFractionDigits: decimals }));\n  const gap = isRange ? minStepsBetweenThumbs * step : 0;\n  const pct = (input: number) => ((input - min) / span) * 100;\n  const valueAt = (percent: number) => min + (percent / 100) * span;\n  const snap = (input: number) => Number((min + Math.round((input - min) / step) * step).toFixed(decimals));\n  const lowOf = (index: number, current: number[]) => (index === 1 ? current[0]! + gap : min);\n  const highOf = (index: number, current: number[]) => (index === 0 && isRange ? current[1]! - gap : max);\n  const settle = (index: number, input: number, current: number[]) =>\n    clamp(snap(clamp(input, min, max)), lowOf(index, current), highOf(index, current));\n\n  const trackRef = useRef<HTMLDivElement>(null);\n  const thumbsRef = useRef<HTMLDivElement>(null);\n  const latest = useRef(values);\n  const goal = useRef(values.map(pct));\n  const drag = useRef<Drag | null>(null);\n  const pointerFocus = useRef(false);\n  const lingerTimer = useRef<number | undefined>(undefined);\n  const [dragging, setDragging] = useState<number | null>(null);\n  const [keyFocus, setKeyFocus] = useState<number | null>(null);\n  const [linger, setLinger] = useState<number | null>(null);\n  const [lastActive, setLastActive] = useState(isRange ? 1 : 0);\n  const [quiet, setQuiet] = useState<number | null>(null);\n\n  // Each thumb is a committed position plus a catch-up offset: a pressed track springs the offset to zero while the pointer moves the\n  // position 1:1, so the thumb glides to the finger and never lags behind it.\n  const pos0 = useMotionValue(pct(values[0]!)),\n    pos1 = useMotionValue(pct(values[1] ?? values[0]!));\n  const lag0 = useMotionValue(0),\n    lag1 = useMotionValue(0);\n  const shown0 = useTransform(() => pos0.get() + lag0.get());\n  const shown1 = useTransform(() => pos1.get() + lag1.get());\n  const clipPath = useTransform(() => {\n    const from = isRange ? clamp(shown0.get(), 0, 100) : 0;\n    const to = clamp((isRange ? shown1 : shown0).get(), 0, 100);\n    return `inset(0 ${(100 - to).toFixed(3)}% 0 ${from.toFixed(3)}% round 999px)`;\n  });\n  const thumbs = [\n    { pos: pos0, lag: lag0, shown: shown0 },\n    { pos: pos1, lag: lag1, shown: shown1 },\n  ];\n\n  const emit = (next: number[]) => (isRange ? [next[0], next[1]] : next[0]) as T;\n  function commit(index: number, next: number) {\n    const current = latest.current;\n    if (current[index] === next) return;\n    const updated = current.map((item, at) => (at === index ? next : item));\n    latest.current = updated;\n    if (value === undefined) setInternal(updated);\n    onValueChange?.(emit(updated));\n  }\n\n  // Values that arrive from outside a drag (keys, props, a clicked mark) spring the thumb to their place.\n  useLayoutEffect(() => {\n    latest.current = values;\n    values.forEach((item, index) => {\n      const target = pct(item),\n        thumb = thumbs[index]!;\n      if (drag.current?.index === index || goal.current[index] === target) return;\n      goal.current[index] = target;\n      const from = thumb.pos.get() + thumb.lag.get();\n      thumb.lag.jump(0);\n      thumb.pos.jump(from);\n      if (reduced) thumb.pos.jump(target);\n      else animate(thumb.pos, target, thumbSpring);\n    });\n  });\n  useEffect(() => () => window.clearTimeout(lingerTimer.current), []);\n\n  const thumbNode = (index: number) => thumbsRef.current?.querySelector<HTMLElement>(`[data-index=\"${index}\"]`);\n  /** Focus that follows a pointer keeps the thumb quiet: no ring and no bubble until a key is pressed. */\n  function focusFromPointer(index: number) {\n    const node = thumbNode(index);\n    if (!node || document.activeElement === node) return;\n    setQuiet(index);\n    pointerFocus.current = true;\n    node.focus({ preventScroll: true });\n    pointerFocus.current = false;\n  }\n  const trackWidth = () => trackRef.current?.getBoundingClientRect().width || 1;\n  function nearest(at: number, current: number[]) {\n    if (!isRange) return 0;\n    const low = Math.abs(at - pct(current[0]!)),\n      high = Math.abs(at - pct(current[1]!));\n    return low === high ? (at < pct(current[0]!) ? 0 : 1) : low < high ? 0 : 1;\n  }\n  function holdBubble(index: number) {\n    window.clearTimeout(lingerTimer.current);\n    setLinger(index);\n    lingerTimer.current = window.setTimeout(() => setLinger(null), 700);\n  }\n\n  /** Starts tracking one thumb. A press on the track springs the thumb over from where it was; a grabbed thumb keeps its grab offset. */\n  function begin(state: Drag, index: number, at: number, press: boolean) {\n    const thumb = thumbs[index]!;\n    const from = thumb.pos.get() + thumb.lag.get();\n    state.index = index;\n    state.grab = press ? 0 : at - from;\n    thumb.lag.jump(0);\n    thumb.pos.jump(from);\n    setDragging(index);\n    setLastActive(index);\n    window.clearTimeout(lingerTimer.current);\n    setLinger(null);\n    focusFromPointer(index);\n    if (press) follow(state, at, 0, true);\n  }\n  /** Places the dragged thumb under the pointer, with resistance past its limits, and commits the stepped value beneath it. */\n  function follow(state: Drag, at: number, time: number, press = false) {\n    const index = state.index;\n    if (index === null) return;\n    const thumb = thumbs[index]!,\n      current = latest.current,\n      width = trackWidth();\n    const raw = at - state.grab;\n    const low = pct(lowOf(index, current)),\n      high = pct(highOf(index, current));\n    const edge = clamp(raw, low, high);\n    const placed = reduced ? edge : edge + (rubber(((raw - edge) / 100) * width) / width) * 100;\n    if (press && !reduced) {\n      const from = thumb.pos.get();\n      thumb.pos.jump(placed);\n      thumb.lag.jump(from - placed);\n      animate(thumb.lag, 0, thumbSpring);\n    } else thumb.pos.set(placed);\n    state.raw = raw;\n    state.samples.push({ t: time, p: placed });\n    if (state.samples.length > 6) state.samples.shift();\n    commit(index, settle(index, valueAt(raw), current));\n  }\n\n  function onPointerDown(event: PointerEvent<HTMLDivElement>) {\n    if (disabled || event.button !== 0 || !event.isPrimary) return;\n    const rect = trackRef.current?.getBoundingClientRect();\n    if (!rect) return;\n    const at = ((event.clientX - rect.left) / (rect.width || 1)) * 100;\n    const grabbed = (event.target as HTMLElement).closest<HTMLElement>(\"[data-thumb]\");\n    const current = latest.current;\n    event.currentTarget.setPointerCapture(event.pointerId);\n    const state: Drag = { pointer: event.pointerId, index: null, grab: 0, x: event.clientX, raw: at, samples: [] };\n    drag.current = state;\n    // Stacked range thumbs wait for the first movement to decide which one the pointer means.\n    if (grabbed && isRange && current[0] === current[1]) {\n      focusFromPointer(Number(grabbed.dataset.index));\n      return;\n    }\n    begin(state, grabbed ? Number(grabbed.dataset.index) : nearest(at, current), at, !grabbed);\n    state.samples = [{ t: event.timeStamp, p: thumbs[state.index ?? 0]!.pos.get() }];\n  }\n  function onPointerMove(event: PointerEvent<HTMLDivElement>) {\n    const state = drag.current;\n    if (!state || state.pointer !== event.pointerId) return;\n    const rect = trackRef.current?.getBoundingClientRect();\n    if (!rect) return;\n    const at = ((event.clientX - rect.left) / (rect.width || 1)) * 100;\n    if (state.index === null) {\n      const dx = event.clientX - state.x;\n      if (Math.abs(dx) < 2) return;\n      begin(state, dx < 0 ? 0 : 1, at - (dx / (rect.width || 1)) * 100, false);\n    }\n    follow(state, at, event.timeStamp);\n  }\n  function onPointerEnd(event: PointerEvent<HTMLDivElement>) {\n    const state = drag.current;\n    if (!state || state.pointer !== event.pointerId) return;\n    drag.current = null;\n    const index = state.index;\n    if (index === null) return;\n    const thumb = thumbs[index]!,\n      current = latest.current,\n      width = trackWidth();\n    // Velocity from the last few frames, in percent per second; a pointer that paused before letting go carries none.\n    const samples = state.samples,\n      first = samples[0],\n      last = samples[samples.length - 1];\n    const elapsed = last && first ? (last.t - first.t) / 1000 : 0;\n    // A release during the catch-up spring keeps that spring's speed too.\n    const velocity =\n      (last && first && elapsed > 0.008 && event.timeStamp - last.t < 60 ? (last.p - first.p) / elapsed : 0) +\n      thumb.lag.getVelocity();\n    // On a coarse grid the release momentum may carry the thumb one step further, never more.\n    const stepPct = (step / span) * 100;\n    const carry =\n      event.type === \"pointerup\" && (stepPct / 100) * width >= SNAP_PX\n        ? clamp(project(velocity), -stepPct, stepPct)\n        : 0;\n    const next = settle(index, valueAt(state.raw + carry), current);\n    const from = thumb.pos.get() + thumb.lag.get(),\n      target = pct(next);\n    thumb.lag.jump(0);\n    thumb.pos.jump(from);\n    goal.current[index] = target;\n    if (reduced) thumb.pos.jump(target);\n    else animate(thumb.pos, target, { ...thumbSpring, velocity });\n    commit(index, next);\n    setDragging(null);\n    holdBubble(index);\n    onValueCommit?.(emit(latest.current));\n  }\n\n  function onKeyDown(index: number, event: KeyboardEvent<HTMLDivElement>) {\n    if (disabled) return;\n    const current = latest.current,\n      now = current[index]!;\n    const large = largeStep ?? Math.max(step, snap(min + span / 10) - min);\n    const moves: Record<string, number> = {\n      ArrowRight: step,\n      ArrowUp: step,\n      ArrowLeft: -step,\n      ArrowDown: -step,\n      PageUp: large,\n      PageDown: -large,\n    };\n    let wanted: number;\n    if (event.key === \"Home\") wanted = lowOf(index, current);\n    else if (event.key === \"End\") wanted = highOf(index, current);\n    else if (event.key in moves)\n      wanted =\n        now +\n        (event.shiftKey && event.key.startsWith(\"Arrow\") ? Math.sign(moves[event.key]!) * large : moves[event.key]!);\n    else return;\n    event.preventDefault();\n    setKeyFocus(index);\n    setQuiet(null);\n    setLastActive(index);\n    const next = settle(index, wanted, current);\n    // At a limit the thumb strains toward the press and springs home, so the key still answers.\n    if (next === now) {\n      const toward = Math.sign(wanted - now);\n      if (toward && !reduced)\n        animate(thumbs[index]!.pos, goal.current[index]!, {\n          ...kickSpring,\n          velocity: ((toward * BUMP_PX) / trackWidth()) * 100,\n        });\n      return;\n    }\n    commit(index, next);\n    onValueCommit?.(emit(latest.current));\n  }\n\n  function jumpTo(target: number) {\n    if (disabled) return;\n    const current = latest.current,\n      index = nearest(pct(target), current);\n    const next = settle(index, target, current);\n    setLastActive(index);\n    focusFromPointer(index);\n    if (next === current[index]) return;\n    commit(index, next);\n    onValueCommit?.(emit(latest.current));\n  }\n\n  const markList = (marks ?? [])\n    .map((mark) => (typeof mark === \"number\" ? ({ value: mark } as SliderMark) : mark))\n    .filter((mark) => mark.value >= min && mark.value <= max);\n  const ticks = markList.filter((mark) => mark.value > min && mark.value < max);\n  const labelled = markList.filter((mark) => mark.label);\n  const inRange = (mark: number) => (isRange ? mark >= values[0]! && mark <= values[1]! : mark <= values[0]!);\n  const names = thumbLabels ?? [`${label}, minimum`, `${label}, maximum`];\n\n  return (\n    <div\n      className={cn(\n        \"group/slider grid w-full min-w-0 text-sm leading-body tracking-body text-ink select-none [--slider-thumb:22px] [--slider-track:6px] data-disabled:opacity-50 data-dragging:cursor-grabbing\",\n        className,\n      )}\n      data-disabled={disabled || undefined}\n      data-dragging={dragging !== null || undefined}\n      data-marks={labelled.length > 0 || undefined}\n    >\n      <div className=\"flex min-w-0 items-baseline justify-between gap-4\">\n        <span id={labelId} className=\"min-w-0 font-medium\">\n          {label}\n        </span>\n        {showValue && (\n          <span\n            className=\"inline-flex flex-none items-center gap-[.3em] whitespace-nowrap text-ink-2 tabular-nums\"\n            aria-hidden=\"true\"\n          >\n            <RollingNumber value={values[0]!} text={formatValue(values[0]!)} />\n            {isRange && (\n              <>\n                <span className=\"text-ink-3\">–</span>\n                <RollingNumber value={values[1]!} text={formatValue(values[1]!)} />\n              </>\n            )}\n          </span>\n        )}\n      </div>\n      <div className=\"grid grid-cols-[auto_minmax(0,1fr)_auto] items-center\">\n        {start && <div className=\"mr-3 flex [grid-area:1/1]\">{start}</div>}\n        {/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- the pointer surface around the track; keyboard access lives on the role=\"slider\" thumb */}\n        <div\n          className=\"relative h-control-md cursor-pointer touch-pan-y [grid-area:1/2] [-webkit-tap-highlight-color:transparent] group-data-dragging/slider:cursor-grabbing group-data-disabled/slider:cursor-not-allowed\"\n          onPointerDown={onPointerDown}\n          onPointerMove={onPointerMove}\n          onPointerUp={onPointerEnd}\n          onPointerCancel={onPointerEnd}\n          onLostPointerCapture={onPointerEnd}\n          onMouseDown={(event) => event.preventDefault()}\n        >\n          <div\n            ref={trackRef}\n            className=\"absolute top-1/2 right-[calc(var(--slider-thumb)/2)] left-[calc(var(--slider-thumb)/2)] mt-[calc(var(--slider-track)/-2)] h-(--slider-track) rounded-full bg-control-track\"\n          >\n            {ticks.map((mark) => (\n              <span\n                key={mark.value}\n                className={cn(tick, \"bg-[color-mix(in_oklab,var(--ink)_30%,transparent)]\")}\n                style={{ left: `${pct(mark.value)}%` }}\n              />\n            ))}\n            <motion.div className=\"absolute inset-0 rounded-[inherit] bg-control-fill\" style={{ clipPath }}>\n              {ticks.map((mark) => (\n                <span\n                  key={mark.value}\n                  className={cn(tick, \"bg-[color-mix(in_oklab,var(--control-glyph)_60%,transparent)]\")}\n                  style={{ left: `${pct(mark.value)}%` }}\n                />\n              ))}\n            </motion.div>\n          </div>\n          <div\n            ref={thumbsRef}\n            className=\"pointer-events-none absolute inset-x-[calc(var(--slider-thumb)/2)] inset-y-0 @container\"\n          >\n            {values.map((item, index) => (\n              <Thumb\n                key={index}\n                quiet={quiet === index}\n                index={index}\n                shown={thumbs[index]!.shown}\n                value={item}\n                text={formatValue(item)}\n                low={lowOf(index, values)}\n                high={highOf(index, values)}\n                ariaLabel={isRange ? names[index] : undefined}\n                labelledBy={labelId}\n                active={lastActive === index}\n                lifted={dragging === index}\n                bubble={dragging === index || keyFocus === index || linger === index}\n                disabled={disabled}\n                onKeyDown={(event) => onKeyDown(index, event)}\n                onFocus={(event) => {\n                  if (!pointerFocus.current && event.currentTarget.matches(\":focus-visible\")) setKeyFocus(index);\n                }}\n                onBlur={() => {\n                  setKeyFocus((current) => (current === index ? null : current));\n                  setQuiet((current) => (current === index ? null : current));\n                }}\n              />\n            ))}\n          </div>\n        </div>\n        {end && <div className=\"ml-3 flex [grid-area:1/3]\">{end}</div>}\n        {labelled.length > 0 && (\n          <div\n            className=\"relative mx-[calc(var(--slider-thumb)/2)] -mt-1 mb-0 h-5 text-xs/[inherit] text-ink-3 tabular-nums [grid-area:2/2]\"\n            aria-hidden=\"true\"\n          >\n            {labelled.map((mark) => (\n              // oxlint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- mark labels are aria-hidden pointer shortcuts; keys reach every value through the thumb\n              <span\n                key={mark.value}\n                className=\"absolute top-0 cursor-pointer whitespace-nowrap transition-[color] duration-fast ease-standard [translate:-50%_0] hover:text-ink data-on:text-ink-2 data-on:hover:text-ink data-[edge=end]:[translate:min(-50%,calc(-100%_+_var(--slider-thumb)/2))_0] data-[edge=start]:[translate:max(-50%,calc(var(--slider-thumb)/-2))_0] group-data-disabled/slider:cursor-not-allowed motion-reduce:transition-none\"\n                data-on={inRange(mark.value) || undefined}\n                data-edge={mark.value === min ? \"start\" : mark.value === max ? \"end\" : undefined}\n                style={{ left: `${pct(mark.value)}%` }}\n                onClick={() => jumpTo(mark.value)}\n              >\n                {mark.label}\n              </span>\n            ))}\n          </div>\n        )}\n      </div>\n      {name &&\n        values.map((item, index) => <input key={index} type=\"hidden\" name={name} value={item} disabled={disabled} />)}\n    </div>\n  );\n}\n\nexport default Slider;\n",
      "type": "registry:component",
      "target": "@components/iris/slider/slider.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/slider",
  "meta": {
    "kind": "component",
    "group": "Inputs",
    "export": "Slider",
    "tags": [
      "slider",
      "range",
      "input"
    ],
    "docs": "https://irisui.dev/components/slider"
  }
}
