{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"wheel-picker","type":"registry:component","title":"Wheel picker","description":"Drums you can fling: each column is a real cylinder that decays onto a row, with a lens in full colour over the middle.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/wheel-picker.tsx","type":"registry:component","content":"// Hairline UI · wheel-picker · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\n/* ─────────────────────────────────────────────────────────\n * WHEEL PICKER: drums you can fling\n *\n *   drum      every column is a real cylinder: options sit on\n *             its faces and tip away into the dark as they\n *             leave the middle, one transform a frame\n *   lens      the middle row is a separate copy of the drum,\n *             clipped to the band, in full colour; a letter\n *             crossing the band's edge is half one, half the\n *             other, like glass over the wheel\n *   fling     drag and let go: the release speed is projected\n *             forward and the wheel decays onto a row, with a\n *             tick on phones for each row it passes; past the\n *             ends of a wheel that doesn't loop it stretches\n *   settle    the value is committed once the wheel rests, and\n *             a unit beside the lens morphs with it (1 hour,\n *             2 hours). Options that can't be chosen are dimmed\n *             and the wheel turns on past them\n *\n * Each wheel is a spinbutton: arrows step, Page Up and Down\n * jump five, Home and End go to the ends; a mouse notch moves\n * one row and taps on a row bring it to the middle.\n * ───────────────────────────────────────────────────────── */\n\nimport * as React from 'react';\nimport { TextMorph } from './text-morph';\nimport { useInteractionReducedMotion } from './ui-interaction-motion';\nimport { SLOTS, STEP, useWheelDrum } from './use-wheel-drum';\n\nexport type WheelValue = string | number;\nexport type WheelOption<T extends WheelValue = WheelValue> = T | { value: T; label?: string; disabled?: boolean };\n\nexport interface WheelPickerProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Row height in px; the drum's radius follows from it. */\n  itemHeight?: number;\n  /** Ticks on phones that can vibrate, one per row. */\n  haptics?: boolean;\n  children: React.ReactNode;\n}\n\nexport interface WheelProps<T extends WheelValue = WheelValue> {\n  options: readonly WheelOption<T>[];\n  value?: NoInfer<T>;\n  defaultValue?: NoInfer<T>;\n  onValueChange?: (value: NoInfer<T>) => void;\n  /** Turn past the end back to the start, like minutes. */\n  loop?: boolean;\n  /** What the wheel sets, for screen readers: \"Hours\". */\n  label: string;\n  /** Fixed beside the lens: \"min\", or a function for \"1 hour\" / \"2 hours\". */\n  unit?: string | ((value: NoInfer<T>) => string);\n  /** Where the options sit in their column. */\n  align?: 'start' | 'center' | 'end';\n  className?: string;\n  style?: React.CSSProperties;\n}\n\nconst Ctx = React.createContext({ itemHeight: 36, haptics: true });\nconst PERSPECTIVE = 1000;\n\nconst valueOf = <T extends WheelValue>(o: WheelOption<T>) => (typeof o === 'object' ? o.value : o);\nconst labelOf = <T extends WheelValue>(o: WheelOption<T>) => (typeof o === 'object' ? (o.label ?? String(o.value)) : String(o));\nconst offOf = <T extends WheelValue>(o: WheelOption<T>) => typeof o === 'object' && Boolean(o.disabled);\n\nexport function WheelPicker({ itemHeight = 36, haptics = true, className = '', style, children, ...props }: WheelPickerProps) {\n  const H = Math.max(28, Math.min(56, itemHeight));\n  const radius = H / (2 * Math.tan((STEP / 2) * (Math.PI / 180)));\n  const ctx = React.useMemo(() => ({ itemHeight: H, haptics }), [H, haptics]);\n  return (\n    <Ctx.Provider value={ctx}>\n      <div role=\"group\" className={`relative flex w-full select-none justify-center px-2 ${className}`} style={{ height: Math.round(radius * 2), ...style }} {...props}>\n        <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 rounded-[11px] bg-muted\" style={{ height: H }} />\n        {children}\n      </div>\n    </Ctx.Provider>\n  );\n}\n\nexport function Wheel<T extends WheelValue = WheelValue>({\n  options,\n  value,\n  defaultValue,\n  onValueChange,\n  loop = false,\n  label,\n  unit,\n  align = 'center',\n  className = '',\n  style,\n}: WheelProps<T>) {\n  const { itemHeight: H, haptics } = React.useContext(Ctx);\n  const reduced = useInteractionReducedMotion();\n  const count = options.length;\n  const indexOf = (v: T | undefined) => Math.max(0, options.findIndex((o) => valueOf(o) === v));\n  const [own, setOwn] = React.useState<T | undefined>(defaultValue ?? (count ? valueOf(options[0]) : undefined));\n  const current = value !== undefined ? value : own;\n  const index = indexOf(current);\n\n  const optionsRef = React.useRef(options);\n  optionsRef.current = options;\n  const report = React.useRef(onValueChange);\n  report.current = onValueChange;\n  const controlled = value !== undefined;\n  const committed = React.useRef(index);\n\n  const drum = useWheelDrum(index, {\n    count,\n    loop,\n    itemHeight: H,\n    reduced,\n    haptics,\n    label: (i) => labelOf(optionsRef.current[i]),\n    disabled: (i) => offOf(optionsRef.current[i]),\n    onRest: (i) => {\n      if (i === committed.current) return;\n      committed.current = i;\n      const next = valueOf(optionsRef.current[i]);\n      if (!controlled) setOwn(next);\n      report.current?.(next);\n    },\n  });\n\n  // The value changed from outside (another wheel clamped it, or the app set it): turn to it.\n  const first = React.useRef(true);\n  React.useEffect(() => {\n    if (first.current) { first.current = false; return; }\n    if (index === committed.current) return;\n    committed.current = index;\n    drum.turnTo(index, !reduced);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [index]);\n\n  // Labels or disabled options changed: relabel the faces in place.\n  const labels = options.map((o) => `${labelOf(o)}${offOf(o) ? '·' : ''}`).join('|');\n  React.useEffect(() => { drum.repaint(); }, [labels]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  // Faces are labelled once here; the drum relabels them directly as it turns.\n  const [faceText] = React.useState(() =>\n    Array.from({ length: SLOTS }, (_, k) => {\n      const i = k + SLOTS * Math.round((index - k) / SLOTS);\n      const real = loop ? ((i % count) + count) % count : i;\n      return count && (loop || (i >= 0 && i < count)) ? labelOf(options[real]) : '';\n    }),\n  );\n\n  const unitOf = (v: T) => (typeof unit === 'function' ? unit(v) : (unit ?? ''));\n  const unitText = current !== undefined && unit ? unitOf(current) : '';\n  const widest = unit ? options.map((o) => unitOf(valueOf(o))).reduce((a, b) => (b.length > a.length ? b : a), '') : '';\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    const moves: Record<string, number> = { ArrowUp: -1, ArrowDown: 1, PageUp: -5, PageDown: 5 };\n    if (e.key in moves) {\n      e.preventDefault();\n      drum.nudge(moves[e.key]);\n    } else if (e.key === 'Home' || e.key === 'End') {\n      e.preventDefault();\n      const to = e.key === 'Home' ? 0 : count - 1;\n      drum.turnTo(to, !reduced);\n    }\n  };\n\n  const justify = align === 'start' ? 'justify-start' : align === 'end' ? 'justify-end' : 'justify-center';\n  const faceClass = `absolute inset-x-0 flex items-center whitespace-nowrap px-2.5 tabular-nums [backface-visibility:hidden] data-[off]:opacity-35 ${justify}`;\n  const face = (k: number) => ({ top: -H / 2, height: H, transform: `rotateX(${-k * STEP}deg) translateZ(${drum.radius}px)` });\n  const edge = `calc(50% - ${H / 2}px)`, edge2 = `calc(50% + ${H / 2}px)`;\n  // The drum fades toward its rim and leaves a hole where the lens copy sits.\n  const mask = `linear-gradient(to bottom, transparent 8%, #000 36%, #000 ${edge}, transparent ${edge}, transparent ${edge2}, #000 ${edge2}, #000 64%, transparent 92%)`;\n  // The first frame's rotation; after that the drum writes its own transform, so React must never touch it.\n  const [current3d] = React.useState(() => ({ transformStyle: 'preserve-3d' as const, transform: `translateZ(${-drum.radius}px) rotateX(${index * STEP}deg)` }));\n\n  return (\n    <div\n      ref={drum.column}\n      role=\"spinbutton\"\n      tabIndex={0}\n      aria-label={label}\n      aria-valuenow={index}\n      aria-valuemin={0}\n      aria-valuemax={Math.max(0, count - 1)}\n      aria-valuetext={`${count ? labelOf(options[index]) : ''}${unitText ? ` ${unitText}` : ''}`}\n      onKeyDown={onKeyDown}\n      className={`group relative flex h-full min-w-0 flex-1 cursor-grab touch-none items-center outline-none active:cursor-grabbing ${className}`}\n      style={style}\n    >\n      <div className=\"relative h-full min-w-0 flex-1 tracking-[-0.01em]\" style={{ fontSize: Math.round(H * 0.56) }}>\n        <div aria-hidden=\"true\" className=\"absolute inset-0 text-muted-foreground\" style={{ perspective: PERSPECTIVE, maskImage: mask, WebkitMaskImage: mask }}>\n          <div ref={drum.drum} className=\"absolute inset-x-0 top-1/2 h-0 will-change-transform\" style={current3d}>\n            {faceText.map((t, k) => (\n              <span key={k} ref={(el) => { drum.faces.current[k] = el; }} className={faceClass} style={face(k)}>{t}</span>\n            ))}\n          </div>\n        </div>\n        <div\n          aria-hidden=\"true\"\n          className=\"absolute inset-x-0 top-1/2 -translate-y-1/2 overflow-hidden rounded-[11px] text-foreground group-focus-visible:ring-2 group-focus-visible:ring-ring/50\"\n          style={{ height: H, perspective: PERSPECTIVE }}\n        >\n          <div ref={drum.lens} className=\"absolute inset-x-0 top-1/2 h-0 will-change-transform\" style={current3d}>\n            {faceText.map((t, k) => (\n              <span key={k} ref={(el) => { drum.lensFaces.current[k] = el; }} className={faceClass} style={face(k)}>{t}</span>\n            ))}\n          </div>\n        </div>\n      </div>\n      {unit && (\n        <span aria-hidden=\"true\" className=\"relative grid shrink-0 pr-2.5 text-[14px] font-medium text-foreground\">\n          <span className=\"invisible col-start-1 row-start-1 whitespace-nowrap\">{widest}</span>\n          <span className=\"col-start-1 row-start-1 whitespace-nowrap\"><TextMorph animateWidth={false}>{unitText}</TextMorph></span>\n        </span>\n      )}\n    </div>\n  );\n}\n"},{"path":"registry/use-wheel-drum.ts","type":"registry:component","content":"// Hairline UI · wheel-picker · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\n\n/*\n * One wheel's motion engine. The options sit on a ring of SLOTS faces round a cylinder; turning\n * the wheel is one rotateX on the drum (and the same on its copy in the lens), so a fling costs two\n * transforms a frame. Only faces turning round the back are relabelled, so a wheel of 60 minutes or\n * 200 years still has 18 faces. Nothing renders through React while it turns: the value is committed\n * once the wheel comes to rest.\n *\n *   drag     follows the finger one row per row height; past the ends of a wheel that doesn't\n *            loop it stretches with resistance\n *   fling    release velocity is projected forward and rounded to a row, then the wheel decays\n *            onto it exponentially, so it lands without a separate snap\n *   wheel    a mouse notch moves one row; trackpad scrolling turns it freely and settles when quiet\n *   tap      a row above or below the lens turns to the middle\n */\n\nexport const SLOTS = 18;\nexport const STEP = 360 / SLOTS;\n\nexport type DrumOptions = {\n  count: number;\n  loop: boolean;\n  itemHeight: number;\n  reduced: boolean;\n  haptics: boolean;\n  label: (index: number) => string;\n  disabled: (index: number) => boolean;\n  /** The wheel came to rest on this option. */\n  onRest: (index: number) => void;\n};\n\nconst mod = (n: number, m: number) => ((n % m) + m) % m;\nconst rubber = (x: number) => (1 - 1 / (x * 0.6 + 1)) / 0.6;\n\nexport function useWheelDrum(initial: number, options: DrumOptions) {\n  const opts = React.useRef(options);\n  opts.current = options;\n  const column = React.useRef<HTMLDivElement>(null);\n  const drum = React.useRef<HTMLDivElement>(null);\n  const lens = React.useRef<HTMLDivElement>(null);\n  const faces = React.useRef<(HTMLElement | null)[]>([]);\n  const lensFaces = React.useRef<(HTMLElement | null)[]>([]);\n  const shown = React.useRef<number[]>([]);\n  const sim = React.useRef({ p: initial, from: initial, target: initial, tau: 0.3, t0: 0, settling: false, rested: initial, tick: Math.round(initial), buzz: 0, touch: false });\n  const frame = React.useRef(0);\n\n  const radius = options.itemHeight / (2 * Math.tan((STEP / 2) * (Math.PI / 180)));\n\n  /** Write the drum's rotation and relabel the faces that crossed the back. */\n  const paint = React.useCallback((force = false) => {\n    const { count, loop, label, disabled, haptics } = opts.current;\n    const s = sim.current;\n    const r = opts.current.itemHeight / (2 * Math.tan((STEP / 2) * (Math.PI / 180)));\n    const transform = `translateZ(${-r}px) rotateX(${s.p * STEP}deg)`;\n    if (drum.current) drum.current.style.transform = transform;\n    if (lens.current) lens.current.style.transform = transform;\n    for (let k = 0; k < SLOTS; k++) {\n      const i = k + SLOTS * Math.round((s.p - k) / SLOTS);\n      if (!force && shown.current[k] === i) continue;\n      shown.current[k] = i;\n      const real = loop ? mod(i, count) : i;\n      const empty = !loop && (i < 0 || i >= count);\n      const text = empty || !count ? '' : label(real);\n      const off = !empty && count > 0 && disabled(real);\n      for (const el of [faces.current[k], lensFaces.current[k]]) {\n        if (!el) continue;\n        if (el.textContent !== text) el.textContent = text;\n        el.toggleAttribute('data-off', off);\n      }\n    }\n    // A tick under the finger for every row that passes the lens (phones that support it).\n    const row = Math.round(s.p);\n    if (row !== s.tick) {\n      s.tick = row;\n      const now = performance.now();\n      if (haptics && s.touch && now - s.buzz > 45 && typeof navigator !== 'undefined' && 'vibrate' in navigator) {\n        s.buzz = now;\n        try { navigator.vibrate(3); } catch { /* not allowed yet */ }\n      }\n    }\n  }, []);\n\n  const rest = React.useCallback(() => {\n    const { count, loop, disabled, onRest } = opts.current;\n    const s = sim.current;\n    s.settling = false;\n    if (!count) return;\n    let index = loop ? mod(Math.round(s.p), count) : Math.max(0, Math.min(count - 1, Math.round(s.p)));\n    // Landed on an option that can't be chosen: turn on to the nearest one that can.\n    if (disabled(index)) {\n      for (let d = 1; d < count; d++) {\n        const down = index - d, up = index + d;\n        const ok = (i: number) => (loop || (i >= 0 && i < count)) && !disabled(mod(i, count));\n        const next = ok(down) ? down : ok(up) ? up : null;\n        if (next !== null) {\n          settleTo(Math.round(s.p) + (next - index), 0.16);\n          return;\n        }\n      }\n      return;\n    }\n    s.rested = index;\n    onRest(index);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  const step = React.useCallback((now: number) => {\n    frame.current = 0;\n    const s = sim.current;\n    if (!s.settling) return;\n    const t = (now - s.t0) / 1000;\n    const k = Math.exp(-t / s.tau);\n    s.p = s.target + (s.from - s.target) * k;\n    if (Math.abs(s.p - s.target) < 0.002) {\n      s.p = s.target;\n      paint();\n      rest();\n      return;\n    }\n    paint();\n    frame.current = requestAnimationFrame(step);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [paint]);\n\n  function settleTo(target: number, tau: number) {\n    const s = sim.current;\n    s.from = s.p;\n    s.target = target;\n    s.tau = tau;\n    s.t0 = performance.now();\n    s.settling = true;\n    if (opts.current.reduced) {\n      s.p = target;\n      paint();\n      rest();\n      return;\n    }\n    cancelAnimationFrame(frame.current);\n    frame.current = requestAnimationFrame(step);\n  }\n\n  const clampTarget = (target: number) => {\n    const { count, loop } = opts.current;\n    return loop ? target : Math.max(0, Math.min(count - 1, target));\n  };\n\n  /** Turn to an option. Loops take the shorter way round. */\n  const turnTo = React.useCallback((index: number, animate = true) => {\n    const { count, loop } = opts.current;\n    const s = sim.current;\n    if (!count) return;\n    let target = index;\n    if (loop) {\n      const base = Math.round(s.settling ? s.target : s.p);\n      const delta = mod(index - mod(base, count) + count / 2, count) - count / 2;\n      target = base + Math.round(delta);\n    }\n    s.rested = mod(index, count);\n    if (!animate) {\n      cancelAnimationFrame(frame.current);\n      frame.current = 0;\n      s.settling = false;\n      s.p = s.target = target;\n      paint();\n      return;\n    }\n    settleTo(target, 0.16);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [paint]);\n\n  /** Move by whole rows from wherever the wheel is heading (keys, mouse notches). */\n  const nudge = React.useCallback((rows: number) => {\n    const s = sim.current;\n    s.touch = false;\n    const base = s.settling ? s.target : Math.round(s.p);\n    settleTo(clampTarget(base + rows), 0.11);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  // Pointer, wheel and the initial paint.\n  React.useEffect(() => {\n    const el = column.current;\n    if (!el) return;\n    paint(true);\n    let drag: { id: number; y: number; p: number; moved: boolean; samples: { y: number; t: number }[] } | null = null;\n    let quiet: ReturnType<typeof setTimeout> | null = null;\n    const s = sim.current;\n    const H = () => opts.current.itemHeight;\n\n    const down = (e: PointerEvent) => {\n      if (e.button !== 0 || drag) return;\n      el.setPointerCapture(e.pointerId);\n      cancelAnimationFrame(frame.current);\n      frame.current = 0;\n      s.settling = false;\n      s.touch = e.pointerType === 'touch';\n      drag = { id: e.pointerId, y: e.clientY, p: s.p, moved: false, samples: [{ y: e.clientY, t: e.timeStamp }] };\n    };\n    const move = (e: PointerEvent) => {\n      if (!drag || e.pointerId !== drag.id) return;\n      const dy = e.clientY - drag.y;\n      if (!drag.moved && Math.abs(dy) < 3) return;\n      drag.moved = true;\n      drag.samples.push({ y: e.clientY, t: e.timeStamp });\n      while (drag.samples.length > 2 && e.timeStamp - drag.samples[0].t > 90) drag.samples.shift();\n      const { count, loop } = opts.current;\n      let p = drag.p - dy / H();\n      if (!loop) {\n        if (p < 0) p = -rubber(-p);\n        else if (p > count - 1) p = count - 1 + rubber(p - (count - 1));\n      }\n      s.p = p;\n      paint();\n    };\n    const up = (e: PointerEvent) => {\n      if (!drag || e.pointerId !== drag.id) return;\n      const d = drag;\n      drag = null;\n      if (!d.moved) {\n        // A tap above or below the lens turns that row to the middle.\n        const r = el.getBoundingClientRect();\n        const r0 = opts.current.itemHeight / (2 * Math.tan((STEP / 2) * (Math.PI / 180)));\n        const off = (Math.asin(Math.max(-1, Math.min(1, (e.clientY - (r.top + r.height / 2)) / r0))) * 180) / Math.PI / STEP;\n        const rows = Math.round(off);\n        if (rows) settleTo(clampTarget(Math.round(s.p) + rows), 0.16);\n        else settleTo(clampTarget(Math.round(s.p)), 0.12);\n        return;\n      }\n      const first = d.samples[0], last = d.samples[d.samples.length - 1];\n      const fresh = e.timeStamp - last.t < 80 && last.t > first.t;\n      const velocity = fresh ? -((last.y - first.y) / (last.t - first.t)) * (1000 / H()) : 0; // rows per second\n      const { count, loop } = opts.current;\n      const outside = !loop && (s.p < 0 || s.p > count - 1);\n      const projected = outside ? s.p : s.p + velocity * 0.3;\n      const target = clampTarget(Math.round(projected));\n      settleTo(target, outside ? 0.14 : Math.abs(target - s.p) < 0.75 ? 0.12 : 0.3);\n    };\n    const wheel = (e: WheelEvent) => {\n      e.preventDefault();\n      s.touch = false;\n      const px = e.deltaMode === 1 ? e.deltaY * H() : e.deltaMode === 2 ? e.deltaY * H() * 5 : e.deltaY;\n      // A mouse wheel notch is one row; a trackpad turns the wheel freely and settles once it goes quiet.\n      if (Math.abs(px) >= 50 && Number.isInteger(e.deltaY)) {\n        nudge(Math.sign(px));\n        return;\n      }\n      cancelAnimationFrame(frame.current);\n      frame.current = 0;\n      s.settling = false;\n      const { count, loop } = opts.current;\n      let p = s.p + px / H();\n      if (!loop) p = Math.max(-0.35, Math.min(count - 1 + 0.35, p));\n      s.p = p;\n      paint();\n      if (quiet) clearTimeout(quiet);\n      quiet = setTimeout(() => settleTo(clampTarget(Math.round(s.p)), 0.14), 120);\n    };\n    el.addEventListener('pointerdown', down);\n    el.addEventListener('pointermove', move);\n    el.addEventListener('pointerup', up);\n    el.addEventListener('pointercancel', up);\n    el.addEventListener('wheel', wheel, { passive: false });\n    return () => {\n      if (quiet) clearTimeout(quiet);\n      cancelAnimationFrame(frame.current);\n      frame.current = 0;\n      el.removeEventListener('pointerdown', down);\n      el.removeEventListener('pointermove', move);\n      el.removeEventListener('pointerup', up);\n      el.removeEventListener('pointercancel', up);\n      el.removeEventListener('wheel', wheel);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  // Options changed (a month with fewer days): relabel every face.\n  const signature = `${options.count}|${options.loop}`;\n  React.useEffect(() => {\n    paint(true);\n  }, [signature, paint]);\n\n  return { column, drum, lens, faces, lensFaces, radius, turnTo, nudge, repaint: () => paint(true), resting: () => sim.current.rested, moving: () => sim.current.settling };\n}\n"},{"path":"registry/ui-interaction-motion.ts","type":"registry:component","content":"// Hairline UI · wheel-picker · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\n\nexport const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';\nexport const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';\nexport const INTERACTION_FOCUS = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';\n\nconst query = '(prefers-reduced-motion: reduce)';\nconst subscribe = (notify: () => void) => {\n  const media = window.matchMedia(query);\n  media.addEventListener('change', notify);\n  return () => media.removeEventListener('change', notify);\n};\nexport const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);\n\nexport function useInteractionActivity<T extends Element>() {\n  const ref = React.useRef<T>(null);\n  const [visible, setVisible] = React.useState(false);\n  const [hidden, setHidden] = React.useState(false);\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));\n    observer.observe(el);\n    const visibility = () => setHidden(document.hidden);\n    visibility(); document.addEventListener('visibilitychange', visibility);\n    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };\n  }, []);\n  return { ref, active: visible && !hidden };\n}\n\nexport function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {\n  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };\n}\n\n/** Sample recent movement rather than averaging a long drag and losing its release velocity. */\nexport type PointerSample = { x: number; time: number };\nexport function samplePointer(samples: PointerSample[], x: number, time: number) {\n  samples.push({ x, time });\n  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();\n}\nexport function releaseVelocity(samples: PointerSample[], now: number) {\n  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;\n  const first = samples[0], last = samples[samples.length - 1];\n  return (last.x - first.x) / Math.max(1, last.time - first.time);\n}\n\n/** A damped spring sampled by WAAPI: no React renders or perpetual frame loop. */\nexport function springFrames(from: number, to: number, velocity = 0, duration = 520) {\n  const omega = 20, damping = 0.82, damped = omega * Math.sqrt(1 - damping * damping);\n  const displacement = from - to;\n  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;\n  return Array.from({ length: 33 }, (_, i) => {\n    const t = (i / 32) * duration / 1000;\n    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));\n    return { value: i === 32 ? to : value, offset: i / 32 };\n  });\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/wheel-picker. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}