{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"feedback","type":"registry:block","title":"Feedback","description":"A score that changes the question: pick 0–10 or stars, the score rolls into the panel, and the follow-up and its tags fit what you picked.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json?as=dependency","https://hairlineui.com/r/status-button.json?as=dependency","https://hairlineui.com/r/text-morph.json?as=dependency","https://hairlineui.com/r/textarea.json?as=dependency"],"files":[{"path":"registry/feedback.tsx","type":"registry:component","content":"// Hairline UI · feedback · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { Check, Star, X } from \"lucide-react\";\nimport { Faces } from \"./faces\";\nimport { NumberRoll } from \"./number-roll\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\nimport { Textarea } from \"./textarea\";\nimport { TextMorph } from \"./text-morph\";\nimport { EASE_THROW, INTERACTION_FOCUS, INTERACTION_PRESS, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * FEEDBACK: the score that changes the question\n *\n *   ask       the lit surface holds the question and a scale\n *             (0–10, or five stars); a light along its top\n *             follows the score you're on\n *   score     the thumb is thrown to your score and the number\n *             rolls into the chip beside the question\n *   follow    the follow-up opens under it and fits the score:\n *             \"What went wrong?\" for a 3, \"What do you love?\" for\n *             a 9, each with its own quick tags; change band and\n *             the question and tags swap the way the score moved\n *   thanks    Send goes Sending → Sent, and the follow-up turns\n *             into the thanks with what you said\n *\n * Where it goes is yours: onSubmit gets { score, band, tags,\n * comment } and sends it to your API, a sheet or an analytics\n * tool. Throw an Error and its message shows under the button.\n * ───────────────────────────────────────────────────────── */\n\nexport type FeedbackBand = \"low\" | \"mid\" | \"high\";\n\nexport interface FeedbackPrompt {\n  question: string;\n  tags: string[];\n}\n\nexport interface FeedbackAnswer {\n  score: number;\n  band: FeedbackBand;\n  tags: string[];\n  comment: string;\n}\n\nexport interface FeedbackProps {\n  question?: string;\n  /** 0–10 (net promoter) or one to five stars. */\n  scale?: \"nps\" | \"stars\";\n  /** Words under each end of the scale. */\n  ends?: [string, string];\n  /** The follow-up for each band; defaults fit a product. */\n  prompts?: Partial<Record<FeedbackBand, FeedbackPrompt>>;\n  onSubmit: (answer: FeedbackAnswer) => Promise<unknown>;\n  /** Shows a close button. */\n  onDismiss?: () => void;\n  className?: string;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst PROMPTS: Record<FeedbackBand, FeedbackPrompt> = {\n  low: { question: \"What went wrong?\", tags: [\"Too slow\", \"Hard to use\", \"Missing a feature\", \"Bugs\", \"Too expensive\"] },\n  mid: { question: \"What would make it a 10?\", tags: [\"Faster\", \"Easier to start\", \"More integrations\", \"Better docs\"] },\n  high: { question: \"What do you love most?\", tags: [\"Speed\", \"Design\", \"Support\", \"Saves me time\"] },\n};\nconst NPS_ENDS: [string, string] = [\"Not likely\", \"Very likely\"];\nconst STAR_ENDS: [string, string] = [\"Poor\", \"Great\"];\nconst STAR_WORDS = [\"Poor\", \"Fair\", \"Okay\", \"Good\", \"Great\"];\n\nconst mix = (color: string, amount: number, into = \"transparent\") => `color-mix(in oklab, ${color} ${amount}%, ${into})`;\nconst PANEL: React.CSSProperties = {\n  backgroundColor: \"var(--primary)\",\n  backgroundImage: `linear-gradient(175deg, transparent 35%, ${mix(\"black\", 26)} 100%)`,\n  boxShadow: `inset 0 1px 0 ${mix(\"var(--primary-foreground)\", 36)}, inset 0 0 0 1px ${mix(\"var(--primary-foreground)\", 12)}`,\n};\nconst CARD: React.CSSProperties = {\n  backgroundImage: `linear-gradient(180deg, var(--card), ${mix(\"var(--card)\", 95, \"var(--foreground)\")})`,\n  boxShadow: `0 0 0 1px var(--border), inset 0 1px 0 ${mix(\"var(--background)\", 85)}`,\n};\nconst LIT: React.CSSProperties = {\n  backgroundImage: `linear-gradient(180deg, ${mix(\"var(--primary-foreground)\", 16)}, transparent 60%)`,\n  boxShadow: `inset 0 1px 0 ${mix(\"var(--primary-foreground)\", 32)}, inset 0 0 0 1px ${mix(\"black\", 10)}`,\n};\nconst TRACK: React.CSSProperties = { background: mix(\"black\", 16), boxShadow: `inset 0 1px 1px ${mix(\"black\", 16)}, inset 0 0 0 1px ${mix(\"var(--primary-foreground)\", 10)}` };\n\nconst bandOf = (scale: \"nps\" | \"stars\", s: number): FeedbackBand => (scale === \"nps\" ? (s <= 6 ? \"low\" : s <= 8 ? \"mid\" : \"high\") : s <= 2 ? \"low\" : s === 3 ? \"mid\" : \"high\");\nconst RANK: Record<FeedbackBand, number> = { low: 0, mid: 1, high: 2 };\n\nexport function Feedback({ question = \"How likely are you to recommend us to a friend?\", scale = \"nps\", ends, prompts, onSubmit, onDismiss, className = \"\" }: FeedbackProps) {\n  const reduced = useInteractionReducedMotion();\n  const baseId = React.useId();\n  const [score, setScore] = React.useState<number | null>(null);\n  const [hover, setHover] = React.useState<number | null>(null);\n  const [tags, setTags] = React.useState<string[]>([]);\n  const [comment, setComment] = React.useState(\"\");\n  const [status, setStatus] = React.useState<ActionStatus>(\"idle\");\n  const [error, setError] = React.useState<string | null>(null);\n  const [sent, setSent] = React.useState(false);\n  const [ready, setReady] = React.useState(false);\n  const tagsRef = React.useRef<HTMLDivElement>(null);\n  const scaleRef = React.useRef<HTMLDivElement>(null);\n  const followRef = React.useRef<HTMLDivElement>(null);\n\n  React.useEffect(() => {\n    const frame = requestAnimationFrame(() => setReady(true));\n    return () => cancelAnimationFrame(frame);\n  }, []);\n\n  const nps = scale === \"nps\";\n  const values = nps ? Array.from({ length: 11 }, (_, i) => i) : [1, 2, 3, 4, 5];\n  const [lo, hi] = ends ?? (nps ? NPS_ENDS : STAR_ENDS);\n  const band = score === null ? null : bandOf(scale, score);\n  const prompt = band ? { ...PROMPTS[band], ...(band === \"mid\" && !nps ? { question: \"What would make it better?\" } : {}), ...prompts?.[band] } : null;\n  const shown = hover ?? score;\n  const idx = shown === null ? -1 : values.indexOf(shown);\n  const busy = status === \"pending\" || status === \"success\";\n\n  // A new band brings its own question and tags in from the side the score moved.\n  const lastBand = React.useRef<FeedbackBand | null>(null);\n  React.useLayoutEffect(() => {\n    const was = lastBand.current;\n    lastBand.current = band;\n    if (!band || !was || was === band || reduced) return;\n    const dir = RANK[band] > RANK[was] ? 1 : -1;\n    tagsRef.current?.querySelectorAll<HTMLElement>(\"[data-tag], [data-prompt]\").forEach((el, i) =>\n      el.animate(\n        [\n          { opacity: 0, transform: `translateX(${dir * 14}px)`, filter: \"blur(4px)\" },\n          { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n        ],\n        { duration: 380, delay: i * 35, easing: EASE, fill: \"backwards\" },\n      ),\n    );\n  }, [band, reduced]);\n\n  const pick = (s: number) => {\n    if (busy || sent) return;\n    if (score === null) window.setTimeout(() => followRef.current?.scrollIntoView({ block: \"nearest\", behavior: reduced ? \"auto\" : \"smooth\" }), 420);\n    if (score !== null && bandOf(scale, s) !== band) setTags([]);\n    setScore(s);\n    setError(null);\n    if (status === \"error\") setStatus(\"idle\");\n  };\n\n  const submit = async () => {\n    if (score === null || !band || busy) return;\n    setStatus(\"pending\");\n    setError(null);\n    try {\n      await onSubmit({ score, band, tags, comment: comment.trim() });\n      setStatus(\"success\");\n      window.setTimeout(() => setSent(true), reduced ? 300 : 650);\n    } catch (e) {\n      setStatus(\"error\");\n      setError(e instanceof Error && e.message ? e.message : \"Couldn’t send that. Try again.\");\n    }\n  };\n\n  const onScaleKey = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    const i = Math.max(0, score === null ? -1 : values.indexOf(score));\n    let next = -1;\n    if (e.key === \"ArrowRight\" || e.key === \"ArrowUp\") next = Math.min(values.length - 1, score === null ? 0 : i + 1);\n    else if (e.key === \"ArrowLeft\" || e.key === \"ArrowDown\") next = Math.max(0, score === null ? 0 : i - 1);\n    else if (e.key === \"Home\") next = 0;\n    else if (e.key === \"End\") next = values.length - 1;\n    else if (/^[0-9]$/.test(e.key) && values.includes(Number(e.key))) next = values.indexOf(Number(e.key));\n    if (next < 0) return;\n    e.preventDefault();\n    pick(values[next]);\n    scaleRef.current?.querySelector<HTMLElement>(`[data-value=\"${values[next]}\"]`)?.focus();\n  };\n\n  // The light along the panel's top sits over the score you're on.\n  const glow = idx < 0 ? 0.5 : (idx + 0.5) / values.length;\n\n  return (\n    <section aria-labelledby={`${baseId}-q`} className={`w-full max-w-[400px] rounded-[28px] p-2 text-card-foreground ${className}`} style={CARD}>\n      {/* Its natural width where the parent sizes to its content, without forcing it on narrow screens. */}\n      <div aria-hidden=\"true\" data-natural-width className=\"grid h-0\" style={{ gridTemplateColumns: \"minmax(0, 400px)\" }} />\n      <div className=\"@container w-full\">\n        {/* The one lit surface: the question and its scale. */}\n        <div className=\"relative overflow-clip rounded-[22px] p-4 text-primary-foreground\" style={PANEL}>\n          <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\" style={{ transform: `translateX(${(glow - 0.5) * 100}%)`, transition: reduced ? \"none\" : `transform 620ms ${EASE}` }}>\n            <span className=\"absolute inset-x-[-30%] -top-1/2 h-full\" style={{ background: `radial-gradient(50% 60% at 50% 50%, ${mix(\"var(--primary-foreground)\", score === null ? 18 : 30)}, transparent 70%)`, transition: \"background 400ms\" }} />\n          </span>\n\n          <div className=\"relative flex items-start gap-3\">\n            <h2 id={`${baseId}-q`} className=\"min-w-0 flex-1 pt-0.5 text-[15px] font-medium leading-snug tracking-[-0.01em]\">\n              {question}\n            </h2>\n            <span\n              aria-hidden=\"true\"\n              className=\"flex h-12 min-w-12 shrink-0 items-center justify-center rounded-[14px] px-2 text-[26px] font-medium leading-none tracking-[-0.04em] tabular-nums\"\n              style={TRACK}\n            >\n              {shown === null ? <span className=\"text-primary-foreground/40\">?</span> : <NumberRoll value={shown} duration={700} />}\n            </span>\n            {onDismiss && (\n              <button type=\"button\" aria-label=\"Close\" onClick={onDismiss} className={`-mr-1.5 -mt-1 flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-primary-foreground/70 hover:bg-primary-foreground/10 hover:text-primary-foreground ${INTERACTION_PRESS}`}>\n                <X aria-hidden=\"true\" className=\"size-4\" />\n              </button>\n            )}\n          </div>\n\n          <div\n            ref={scaleRef}\n            role=\"radiogroup\"\n            aria-labelledby={`${baseId}-q`}\n            onKeyDown={onScaleKey}\n            onPointerLeave={() => setHover(null)}\n            className={`relative mt-3.5 grid rounded-full p-1 ${nps ? \"h-10\" : \"h-11\"}`}\n            style={{ ...TRACK, gridTemplateColumns: `repeat(${values.length}, minmax(0, 1fr))` }}\n          >\n            {nps && (\n              <span\n                aria-hidden=\"true\"\n                className=\"pointer-events-none absolute bottom-1 left-1 top-1 rounded-full\"\n                style={{\n                  width: `calc((100% - 8px) / ${values.length})`,\n                  background: \"var(--primary-foreground)\",\n                  boxShadow: `0 1px 0 ${mix(\"black\", 14)}`,\n                  opacity: idx < 0 ? 0 : hover !== null && hover !== score ? 0.55 : 1,\n                  transform: `translateX(${Math.max(0, idx) * 100}%)`,\n                  transition: reduced || !ready ? \"opacity 160ms\" : `transform 460ms ${EASE_THROW}, opacity 200ms`,\n                }}\n              />\n            )}\n            {values.map((v, i) => {\n              const on = v === score;\n              const filled = !nps && shown !== null && v <= shown;\n              return (\n                <button\n                  key={v}\n                  type=\"button\"\n                  role=\"radio\"\n                  data-value={v}\n                  aria-checked={on}\n                  aria-label={nps ? String(v) : `${v} ${v === 1 ? \"star\" : \"stars\"}, ${STAR_WORDS[v - 1]}`}\n                  tabIndex={on || (score === null && i === 0) ? 0 : -1}\n                  disabled={sent}\n                  onClick={() => pick(v)}\n                  onPointerMove={(e) => e.pointerType === \"mouse\" && hover !== v && setHover(v)}\n                  className={`relative z-10 flex cursor-pointer items-center justify-center rounded-full text-[13px] font-medium tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-primary-foreground/70 disabled:cursor-default ${INTERACTION_PRESS}`}\n                  style={{ color: nps && (hover ?? score) === v ? \"var(--primary)\" : undefined, transition: \"color 200ms\" }}\n                >\n                  {nps ? (\n                    <span className={on || hover === v ? \"\" : \"text-primary-foreground/80\"}>{v}</span>\n                  ) : (\n                    <Star\n                      aria-hidden=\"true\"\n                      className=\"size-[22px]\"\n                      strokeWidth={1.6}\n                      style={{\n                        fill: filled ? \"var(--primary-foreground)\" : \"transparent\",\n                        color: filled ? \"var(--primary-foreground)\" : mix(\"var(--primary-foreground)\", 55),\n                        transform: filled && !reduced ? \"scale(1.08)\" : \"none\",\n                        transition: reduced ? \"none\" : `fill 220ms ${EASE} ${i * 40}ms, color 220ms ${EASE} ${i * 40}ms, transform 360ms ${EASE_THROW} ${i * 40}ms`,\n                      }}\n                    />\n                  )}\n                </button>\n              );\n            })}\n          </div>\n          <div className=\"relative mt-1.5 flex justify-between px-1 text-[11.5px] text-primary-foreground/65\">\n            <span>{lo}</span>\n            {!nps && shown !== null && (\n              <span className=\"text-primary-foreground/90\">\n                <TextMorph>{STAR_WORDS[shown - 1]}</TextMorph>\n              </span>\n            )}\n            <span>{hi}</span>\n          </div>\n        </div>\n\n        {/* Flat: the follow-up that fits the score, then the thanks. */}\n        <div className=\"grid\" style={{ gridTemplateRows: score === null ? \"0fr\" : \"1fr\", opacity: score === null ? 0 : 1, transition: reduced ? \"opacity 160ms\" : `grid-template-rows 460ms ${EASE}, opacity 320ms ${EASE} 80ms` }}>\n          <div ref={followRef} className=\"min-h-0 overflow-clip\" inert={score === null || undefined}>\n            <Faces active={sent ? 1 : 0} reduced={reduced} ready={ready}>\n              <form\n                className=\"px-2 pb-1 pt-3\"\n                onSubmit={(e) => {\n                  e.preventDefault();\n                  void submit();\n                }}\n              >\n                <div ref={tagsRef}>\n                  <p data-prompt className=\"px-1 text-[14px] font-medium tracking-[-0.01em]\">\n                    {prompt?.question}\n                  </p>\n                  <div role=\"group\" aria-label={prompt?.question} className=\"mt-2 flex flex-wrap gap-1.5\">\n                    {prompt?.tags.map((t) => {\n                      const on = tags.includes(t);\n                      return (\n                        <button\n                          key={`${band}-${t}`}\n                          data-tag\n                          type=\"button\"\n                          aria-pressed={on}\n                          disabled={busy}\n                          onClick={() => setTags((list) => (on ? list.filter((x) => x !== t) : [...list, t]))}\n                          className={`inline-flex h-8 cursor-pointer items-center rounded-full px-3 text-[12.5px] disabled:cursor-default ${INTERACTION_PRESS} ${INTERACTION_FOCUS} ${\n                            on ? \"bg-primary/[0.08] font-medium text-primary shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--primary)_40%,transparent)]\" : \"text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent\"\n                          }`}\n                        >\n                          <span className=\"grid\" style={{ gridTemplateColumns: on ? \"1fr\" : \"0fr\", transition: reduced ? \"none\" : `grid-template-columns 300ms ${EASE}` }}>\n                            <span className=\"min-w-0 overflow-hidden\">\n                              <Check aria-hidden=\"true\" className=\"mr-1 size-3.5\" strokeWidth={2.5} />\n                            </span>\n                          </span>\n                          {t}\n                        </button>\n                      );\n                    })}\n                  </div>\n                </div>\n                <Textarea aria-label=\"Anything else?\" value={comment} onValueChange={setComment} disabled={busy} placeholder=\"Anything else? (optional)\" minRows={2} maxRows={5} maxLength={1000} showCount={false} className=\"mt-2.5\" />\n                <StatusButton\n                  type=\"submit\"\n                  status={status}\n                  labels={{ idle: \"Send feedback\", pending: \"Sending\", success: \"Sent\", error: \"Try again\" }}\n                  className=\"mt-2.5 h-11 w-full text-[14.5px]\"\n                  style={status === \"error\" ? undefined : LIT}\n                />\n                {error && (\n                  <p role=\"alert\" className=\"mt-1.5 text-center text-[12px] text-red-600 dark:text-red-400\">\n                    {error}\n                  </p>\n                )}\n              </form>\n\n              <div className=\"flex items-start gap-3 px-3 pb-2 pt-4\" role=\"status\">\n                <span className=\"flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary\">\n                  <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-4\" aria-hidden=\"true\">\n                    <path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" pathLength={1} strokeDasharray={1} style={{ strokeDashoffset: sent ? 0 : 1, transition: sent && !reduced ? `stroke-dashoffset 460ms ${EASE} 240ms` : \"none\" }} />\n                  </svg>\n                </span>\n                <div className=\"min-w-0\">\n                  <p className=\"text-[14.5px] font-medium tracking-[-0.01em]\">{sent ? \"Thanks, that helps.\" : \"\"}</p>\n                  <p className=\"mt-0.5 text-[12.5px] text-muted-foreground\">\n                    {sent && score !== null ? `You gave ${nps ? score : `${score} of 5`}${tags.length ? ` · ${tags.join(\", \")}` : \"\"}` : \"\"}\n                  </p>\n                </div>\n              </div>\n            </Faces>\n          </div>\n        </div>\n      </div>\n    </section>\n  );\n}\n\nexport default Feedback;\n"},{"path":"registry/faces.tsx","type":"registry:component","content":"// Hairline UI · feedback · 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 * FACES: the states of one surface, in the same place\n *\n * The active face is in flow; the others are laid over it, faded\n * and nudged aside (faces before it to the left, faces after it to\n * the right), so switching reads as the surface turning to its\n * next side. The height eases between the faces' measured heights,\n * so nothing around it jumps. Used by the blocks (the wallet's\n * activity and keypad, a pricing card and its checkout, a booking's\n * times, details and confirmation).\n * ───────────────────────────────────────────────────────── */\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nexport const FACES_MORPH = 460;\n\nexport function Faces({\n  active,\n  reduced,\n  ready,\n  className = \"\",\n  children,\n}: {\n  active: number;\n  reduced: boolean;\n  /** False until the first paint, so the faces don't animate into place on mount. */\n  ready: boolean;\n  className?: string;\n  children: React.ReactNode[];\n}) {\n  const refs = React.useRef<(HTMLDivElement | null)[]>([]);\n  const [heights, setHeights] = React.useState<number[] | null>(null);\n  const count = children.length;\n\n  React.useLayoutEffect(() => {\n    const els = refs.current.slice(0, count);\n    const observer = new ResizeObserver(() => {\n      const next = els.map((el) => el?.offsetHeight ?? 0);\n      setHeights((p) => (p && p.length === next.length && p.every((h, i) => h === next[i]) ? p : next));\n    });\n    els.forEach((el) => el && observer.observe(el));\n    return () => observer.disconnect();\n  }, [count]);\n\n  const face = (i: number): React.CSSProperties => {\n    const on = i === active;\n    return {\n      opacity: on ? 1 : 0,\n      transform: on || reduced ? \"none\" : `translateX(${i < active ? -18 : 18}px)`,\n      filter: on || reduced ? \"none\" : \"blur(4px)\",\n      transition: !ready\n        ? \"none\"\n        : reduced\n          ? \"opacity 160ms ease-out\"\n          : on\n            ? `opacity 320ms ${EASE} 90ms, transform ${FACES_MORPH}ms ${EASE}, filter 320ms ${EASE} 90ms`\n            : `opacity 140ms ease-out, transform ${FACES_MORPH}ms ${EASE}, filter 200ms ease-out`,\n    };\n  };\n\n  return (\n    <div className={`relative overflow-clip ${className}`} style={{ height: heights ? heights[active] : undefined, transition: ready && !reduced ? `height ${FACES_MORPH}ms ${EASE}` : \"none\" }}>\n      {children.map((child, i) => (\n        <div\n          key={i}\n          ref={(el) => {\n            refs.current[i] = el;\n          }}\n          inert={active !== i || undefined}\n          aria-hidden={active !== i || undefined}\n          className={active === i ? \"relative\" : \"pointer-events-none absolute inset-x-0 top-0\"}\n          style={face(i)}\n        >\n          {child}\n        </div>\n      ))}\n    </div>\n  );\n}\n"},{"path":"registry/ui-interaction-motion.ts","type":"registry:component","content":"// Hairline UI · feedback · 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)';\n/** Thrown things (indicators, thumbs): a slight overshoot on transform only. */\nexport const EASE_THROW = 'cubic-bezier(0.34, 1.36, 0.64, 1)';\n/**\n * The press every button primitive shares: a 3% give that eases in and settles back.\n * Tailwind v4's scale-* writes the `scale` property, so `scale` has to be in the list\n * or the press snaps.\n */\nexport const INTERACTION_PRESS =\n  'transition-[background-color,color,box-shadow,opacity,scale] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] active:scale-[0.97] motion-reduce:transition-none motion-reduce:active:scale-100';\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/** Smooth Apple/rational rubber-band curve: continuous exponential-like resistance without linear kinks. */\nexport function rubberBand(overshoot: number, dimension = 320, constant = 0.55): number {\n  const d = dimension || 320;\n  const abs = Math.abs(overshoot);\n  return Math.sign(overshoot) * (1 - 1 / ((abs * constant) / d + 1)) * d;\n}\n\n/** Projects resting travel from release velocity (velocity in px/ms; time in seconds). */\nexport function projectInertia(distance: number, velocity: number, time = 0.12): number {\n  return distance + velocity * 1000 * time;\n}\n\n/** Smooths incoming release velocity toward a boundary so springs don't slam or clip into walls. */\nexport function softenVelocity(velocity: number, distanceToTarget: number, factor = 10): number {\n  const maxAllowed = Math.abs(distanceToTarget) * factor;\n  return Math.sign(velocity) * Math.min(Math.abs(velocity), maxAllowed);\n}\n\n/** A damped spring sampled by WAAPI: tuned for organic lightness with a hint of life (3% breath). */\nexport function springFrames(\n  from: number,\n  to: number,\n  velocity = 0,\n  duration = 480,\n  config: { omega?: number; damping?: number; steps?: number } = {},\n) {\n  const { omega = 16, damping = 0.74, steps = 36 } = config;\n  const damped = omega * Math.sqrt(Math.max(0.001, 1 - damping * damping));\n  const displacement = from - to;\n  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;\n  return Array.from({ length: steps + 1 }, (_, i) => {\n    const t = (i / steps) * (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 === steps ? to : value, offset: i / steps };\n  });\n}\n\n"}],"css":{"@keyframes ui-col-in":{"from":{"max-width":"0","opacity":"0"},"to":{"max-width":"1em","opacity":"1"}},"@keyframes ui-col-out":{"from":{"max-width":"1em","opacity":"1"},"to":{"max-width":"0","opacity":"0"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/feedback. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}