{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"referral","type":"registry:block","title":"Referral","description":"A referral pass that fills as friends join: their faces fly up into its notches, and the last one turns the track into Claim.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/copy-button.json?as=dependency","https://hairlineui.com/r/number-roll.json?as=dependency","https://hairlineui.com/r/status-button.json?as=dependency","https://hairlineui.com/r/tag-input.json?as=dependency","https://hairlineui.com/r/text-morph.json?as=dependency"],"files":[{"path":"registry/referral.tsx","type":"registry:component","content":"// Hairline UI · referral · 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, Gift } from \"lucide-react\";\nimport { CopyButton } from \"./copy-button\";\nimport { NumberRoll } from \"./number-roll\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\nimport { TagInput, type TagInputTag } from \"./tag-input\";\nimport { TextMorph } from \"./text-morph\";\nimport { useRowMotion } from \"./use-row-motion\";\nimport { EASE_THROW, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * REFERRAL: a reward that fills as your friends join\n *\n *   pass      the lit surface: what you give and get, your link\n *             with Copy, and a track of notches, one per friend\n *             the reward needs\n *   invite    emails become chips; Send invites goes Sending →\n *             Sent and the friends slide into the list as Invited\n *   joined    when a friend joins, their face lifts out of the\n *             list and flies up into the next notch, which lights;\n *             \"Invited\" morphs to \"Joined\" and the count rolls\n *   unlocked  the last notch lands and the notches close in on\n *             the middle and become the Claim button\n *\n * The programme is yours: pass the link and the friends (with\n * their status from your database), and do the work in onInvite\n * and onClaim. Throw an Error and its message shows in place.\n * ───────────────────────────────────────────────────────── */\n\nexport interface ReferralFriend {\n  id: string;\n  email: string;\n  name?: string;\n  status: \"invited\" | \"joined\";\n}\n\nexport interface ReferralProps {\n  /** Your personal invite link. */\n  link: string;\n  /** Newest first, from your database. */\n  friends: ReferralFriend[];\n  /** Friends who need to join for the reward. */\n  goal: number;\n  /** What you get, e.g. \"a free month of Pro\". */\n  reward: string;\n  /** What your friends get, e.g. \"Friends get 20% off their first year\". */\n  offer?: string;\n  claimed?: boolean;\n  /** Send the invites; throw an Error to say why not (the chips stay). */\n  onInvite?: (emails: string[]) => Promise<unknown>;\n  /** Give the reward; resolve once it's on the account. */\n  onClaim?: () => Promise<unknown>;\n  className?: string;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst SLOT = 34;\nconst NO_TAGS: TagInputTag[] = [];\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n\nconst mix = (color: string, amount: number, into = \"transparent\") => `color-mix(in oklab, ${color} ${amount}%, ${into})`;\nconst PASS: React.CSSProperties = {\n  backgroundColor: \"var(--primary)\",\n  backgroundImage: `radial-gradient(120% 90% at 100% 0%, ${mix(\"var(--primary-foreground)\", 22)} 0%, transparent 55%), linear-gradient(190deg, transparent 40%, ${mix(\"black\", 24)} 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};\n\nfunction tint(seed: string): React.CSSProperties {\n  let h = 0;\n  for (const c of seed) h = (h * 31 + c.charCodeAt(0)) >>> 0;\n  const chart = `var(--chart-${(h % 5) + 1}, var(--primary))`;\n  return { background: mix(chart, 20, \"var(--card)\"), color: mix(chart, 72, \"var(--foreground)\"), boxShadow: `inset 0 0 0 1px ${mix(chart, 26)}` };\n}\n\nconst nameOf = (f: ReferralFriend) => f.name ?? f.email.split(\"@\")[0];\nconst initials = (f: ReferralFriend) =>\n  (f.name ?? f.email.split(\"@\")[0].replace(/[._-]+/g, \" \"))\n    .split(/\\s+/)\n    .filter(Boolean)\n    .slice(0, 2)\n    .map((w) => w[0]?.toUpperCase())\n    .join(\"\");\n\nexport function Referral({ link, friends, goal, reward, offer, claimed: claimedProp, onInvite, onClaim, className = \"\" }: ReferralProps) {\n  const reduced = useInteractionReducedMotion();\n  const baseId = React.useId();\n  const [tags, setTags] = React.useState<TagInputTag[]>(NO_TAGS);\n  const [invite, setInvite] = React.useState<{ status: ActionStatus; error?: string }>({ status: \"idle\" });\n  const [claim, setClaim] = React.useState<{ status: ActionStatus; error?: string }>({ status: \"idle\" });\n  const [said, setSaid] = React.useState(\"\");\n  // Friends whose faces are still flying up to the pass: their notch lights when they land.\n  const [landing, setLanding] = React.useState<ReadonlySet<string>>(() => new Set());\n\n  const sectionRef = React.useRef<HTMLElement>(null);\n  const listRef = React.useRef<HTMLUListElement>(null);\n  const ghostRef = React.useRef<HTMLDivElement>(null);\n  const slotRefs = React.useRef<(HTMLSpanElement | null)[]>([]);\n\n  // Someone just joined: hold their notch unlit while their face flies up to it (decided while rendering, so it never flashes lit).\n  const [seen, setSeen] = React.useState(friends);\n  if (friends !== seen) {\n    const fresh = friends.filter((f) => f.status === \"joined\" && seen.some((p) => p.id === f.id && p.status === \"invited\"));\n    setSeen(friends);\n    if (fresh.length) {\n      setSaid(`${fresh.map(nameOf).join(\", \")} joined.`);\n      if (!reduced) setLanding((s) => new Set([...s, ...fresh.map((f) => f.id)]));\n    }\n  }\n\n  const joined = friends.filter((f) => f.status === \"joined\").reverse(); // oldest first, so notches fill in order\n  const lit = joined.filter((f) => !landing.has(f.id));\n  const done = lit.length >= goal;\n  const claimed = claimedProp ?? claim.status === \"success\";\n  const left = Math.max(0, goal - lit.length);\n\n  const ranks = React.useMemo(() => new Map(friends.map((f, i) => [f.id, i])), [friends]);\n  useRowMotion(listRef, { reduced, ranks, ghosts: ghostRef });\n\n  // Their face lifts out of their row and flies up to their notch, which lights as it lands.\n  const statuses = React.useRef(new Map(friends.map((f) => [f.id, f.status])));\n  React.useLayoutEffect(() => {\n    const before = statuses.current;\n    statuses.current = new Map(friends.map((f) => [f.id, f.status]));\n    const fresh = friends.filter((f) => f.status === \"joined\" && before.get(f.id) === \"invited\");\n    if (!fresh.length || reduced) return;\n    const section = sectionRef.current;\n    const order = friends.filter((f) => f.status === \"joined\").reverse();\n    const land = (id: string) =>\n      setLanding((set) => {\n        const next = new Set(set);\n        next.delete(id);\n        return next;\n      });\n    if (!section) return fresh.forEach((f) => requestAnimationFrame(() => land(f.id)));\n    const box = section.getBoundingClientRect();\n    const flights: { id: string; from: DOMRect; to: DOMRect; f: ReferralFriend }[] = [];\n    for (const f of fresh) {\n      const face = listRef.current?.querySelector<HTMLElement>(`[data-row-id=\"${f.id}\"] [data-face]`);\n      const slot = slotRefs.current[order.indexOf(f)];\n      if (face && slot) flights.push({ id: f.id, from: face.getBoundingClientRect(), to: slot.getBoundingClientRect(), f });\n      else requestAnimationFrame(() => land(f.id));\n    }\n    flights.forEach(({ id, from, to, f }, i) => {\n      const el = document.createElement(\"span\");\n      el.setAttribute(\"aria-hidden\", \"true\");\n      el.textContent = initials(f);\n      Object.assign(el.style, tint(f.email), {\n        position: \"absolute\",\n        left: `${from.left - box.left}px`,\n        top: `${from.top - box.top}px`,\n        width: `${from.width}px`,\n        height: `${from.height}px`,\n        borderRadius: \"999px\",\n        display: \"flex\",\n        alignItems: \"center\",\n        justifyContent: \"center\",\n        fontSize: \"11px\",\n        fontWeight: \"500\",\n        zIndex: \"20\",\n        pointerEvents: \"none\",\n        willChange: \"transform\",\n      });\n      section.appendChild(el);\n      const dx = to.left + to.width / 2 - (from.left + from.width / 2);\n      const dy = to.top + to.height / 2 - (from.top + from.height / 2);\n      const s = to.width / from.width;\n      const a = el.animate(\n        [\n          { transform: \"translate(0,0) scale(1)\", offset: 0 },\n          { transform: `translate(${dx * 0.35}px, ${dy * 0.55 - 36}px) scale(${1.25})`, offset: 0.45 },\n          { transform: `translate(${dx}px, ${dy}px) scale(${s})`, offset: 1 },\n        ],\n        { duration: 820, delay: 140 + i * 160, easing: \"cubic-bezier(0.45,0,0.2,1)\", fill: \"forwards\" },\n      );\n      a.onfinish = () => {\n        land(id);\n        el.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 180, fill: \"forwards\" }).onfinish = () => el.remove();\n      };\n      a.oncancel = () => {\n        land(id);\n        el.remove();\n      };\n    });\n  }, [friends, reduced]);\n\n  const send = async () => {\n    const emails = tags.filter((t) => !t.error).map((t) => t.value);\n    if (!emails.length || invite.status === \"pending\") return;\n    setInvite({ status: \"pending\" });\n    try {\n      await onInvite?.(emails);\n      setInvite({ status: \"success\" });\n      setTags(NO_TAGS);\n      setSaid(`Invited ${emails.length === 1 ? emails[0] : `${emails.length} friends`}.`);\n    } catch (e) {\n      setInvite({ status: \"error\", error: e instanceof Error && e.message ? e.message : \"Couldn’t send those invites.\" });\n    }\n  };\n\n  const claimNow = async () => {\n    if (claim.status === \"pending\" || claimed) return;\n    setClaim({ status: \"pending\" });\n    try {\n      await onClaim?.();\n      setClaim({ status: \"success\" });\n      setSaid(`Claimed ${reward}.`);\n    } catch (e) {\n      setClaim({ status: \"error\", error: e instanceof Error && e.message ? e.message : \"Couldn’t claim it. Try again.\" });\n    }\n  };\n\n  const good = tags.filter((t) => !t.error).length;\n  const valid = good > 0;\n  const invited = friends.length;\n\n  return (\n    <section ref={sectionRef} aria-labelledby={`${baseId}-title`} className={`relative w-full max-w-[420px] 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, 420px)\" }} />\n      <div className=\"@container w-full\">\n        {/* The one lit surface: the pass, with a notch for every friend the reward needs. */}\n        <div className=\"rounded-[22px] p-4 text-primary-foreground\" style={PASS}>\n          <div className=\"flex items-center justify-between gap-3 text-[12.5px]\">\n            <span className=\"inline-flex items-center gap-1.5 text-primary-foreground/75\">\n              <Gift aria-hidden=\"true\" className=\"size-3.5\" />\n              Invite friends\n            </span>\n            <span className=\"tabular-nums text-primary-foreground/75\">\n              <NumberRoll value={Math.min(lit.length, goal)} duration={700} className=\"font-medium text-primary-foreground\" /> of {goal} joined\n            </span>\n          </div>\n          <h2 id={`${baseId}-title`} className=\"mt-2 text-[21px] font-medium leading-[1.15] tracking-[-0.03em]\">\n            <TextMorph animateWidth={false}>{claimed ? \"Reward claimed\" : done ? \"You unlocked it\" : `${left} more ${left === 1 ? \"friend\" : \"friends\"}`}</TextMorph>\n            <span className=\"block text-primary-foreground/70\">{claimed ? `Enjoy ${reward}.` : `and you get ${reward}.`}</span>\n          </h2>\n\n          {/* The track: notches that light as friends join, then close in and become Claim. */}\n          <div className=\"relative mt-4 grid h-[34px]\">\n            <div className=\"flex items-center [grid-area:1/1]\" aria-hidden={done || undefined}>\n              {Array.from({ length: goal }, (_, i) => {\n                const f = lit[i];\n                const on = Boolean(f);\n                const center = (goal - 1) / 2;\n                return (\n                  <React.Fragment key={i}>\n                    {i > 0 && (\n                      <span className=\"h-px flex-1\" style={{ background: lit[i] ? \"var(--primary-foreground)\" : mix(\"var(--primary-foreground)\", 26), opacity: done ? 0 : 1, transition: reduced ? \"none\" : `background-color 300ms, opacity 240ms ${EASE}` }} />\n                    )}\n                    <span\n                      ref={(el) => {\n                        slotRefs.current[i] = el;\n                      }}\n                      className=\"relative flex shrink-0 items-center justify-center rounded-full text-[11px] font-medium\"\n                      style={{\n                        width: SLOT,\n                        height: SLOT,\n                        color: \"var(--primary)\",\n                        background: on ? \"var(--primary-foreground)\" : \"transparent\",\n                        boxShadow: on ? `0 0 0 3px ${mix(\"var(--primary-foreground)\", 20)}` : `inset 0 0 0 1px ${mix(\"var(--primary-foreground)\", 40)}`,\n                        opacity: done ? 0 : 1,\n                        transform: done && !reduced ? `translateX(calc(${center - i} * (100cqw - ${48 + SLOT}px) / ${Math.max(1, goal - 1)} * 0.9)) scale(0.6)` : \"none\",\n                        transition: reduced\n                          ? \"opacity 160ms\"\n                          : `background-color 260ms ${EASE}, box-shadow 420ms ${EASE_THROW}, transform 520ms ${EASE} ${i * 30}ms, opacity 360ms ${EASE} ${120 + i * 30}ms`,\n                      }}\n                    >\n                      {on ? initials(f) : <span className=\"text-[14px] leading-none\" style={{ color: mix(\"var(--primary-foreground)\", 55) }}>+</span>}\n                    </span>\n                  </React.Fragment>\n                );\n              })}\n            </div>\n            <div\n              className=\"[grid-area:1/1]\"\n              inert={!done || undefined}\n              style={{\n                clipPath: done ? \"inset(0 0 round 999px)\" : `inset(0 calc(50% - ${SLOT / 2}px) round 999px)`,\n                opacity: done ? 1 : 0,\n                transition: reduced ? \"opacity 160ms\" : `clip-path 560ms ${EASE} 180ms, opacity 200ms ${EASE} ${done ? 180 : 0}ms`,\n              }}\n            >\n              <StatusButton\n                status={claimed ? \"success\" : claim.status}\n                labels={{ idle: `Claim ${reward}`, pending: \"Claiming\", success: \"Claimed\", error: \"Try again\" }}\n                icon={<Gift aria-hidden=\"true\" />}\n                onClick={() => void claimNow()}\n                className=\"h-[34px] w-full text-[13.5px] hover:opacity-95\"\n                style={claim.status === \"error\" ? { background: \"var(--primary-foreground)\" } : { background: \"var(--primary-foreground)\", color: \"var(--primary)\" }}\n              />\n            </div>\n          </div>\n          {claim.error && (\n            <p role=\"alert\" className=\"mt-2 text-[12px] text-primary-foreground/85\">\n              {claim.error}\n            </p>\n          )}\n\n          {/* Your link, inset into the pass. */}\n          <div className=\"mt-4 flex h-10 items-center gap-2 rounded-full pl-4 pr-1\" style={{ background: mix(\"black\", 14), boxShadow: `inset 0 1px 0 ${mix(\"black\", 12)}, inset 0 0 0 1px ${mix(\"var(--primary-foreground)\", 10)}` }}>\n            <span className=\"min-w-0 flex-1 truncate font-mono text-[12.5px] text-primary-foreground/90\">{link.replace(/^https?:\\/\\//, \"\")}</span>\n            <CopyButton text={link} label=\"Copy link\" copiedLabel=\"Copied\" showLabel size=\"sm\" variant=\"secondary\" className=\"shrink-0\" />\n          </div>\n          {offer && <p className=\"mt-2 px-1 text-[12px] text-primary-foreground/65\">{offer}</p>}\n        </div>\n\n        {/* Flat: invite by email, then the people you've asked. */}\n        <form\n          className=\"px-2 pt-3\"\n          onSubmit={(e) => {\n            e.preventDefault();\n            void send();\n          }}\n        >\n          <TagInput\n            value={tags}\n            onValueChange={(next) => {\n              setTags(next);\n              if (invite.status !== \"idle\" && invite.status !== \"pending\") setInvite({ status: \"idle\" });\n            }}\n            label=\"Invite by email\"\n            placeholder=\"friend@company.com\"\n            separators={/[\\s,;]+/}\n            normalize={(v) => v.toLowerCase()}\n            validate={(v) => (!EMAIL.test(v) ? `“${v}” isn’t an email address` : friends.some((f) => f.email.toLowerCase() === v) ? `${v} is already invited` : null)}\n            disabled={invite.status === \"pending\"}\n          />\n          <div className=\"mt-2 flex items-center justify-between gap-3\">\n            <p role={invite.error ? \"alert\" : undefined} className={`min-w-0 text-[12px] ${invite.error ? \"text-red-600 dark:text-red-400\" : \"text-muted-foreground\"}`}>\n              {invite.error ?? \"Paste a list, or press Enter after each.\"}\n            </p>\n            <StatusButton\n              type=\"submit\"\n              status={invite.status}\n              labels={{ idle: good > 1 ? `Send ${good} invites` : \"Send invite\", pending: \"Sending\", success: \"Sent\", error: \"Try again\" }}\n              onReset={() => setInvite({ status: \"idle\" })}\n              resetAfter={1600}\n              disabled={!valid && invite.status === \"idle\"}\n              className=\"h-9 shrink-0 px-4 text-[13px] disabled:opacity-45\"\n              style={invite.status === \"error\" ? undefined : LIT}\n            />\n          </div>\n        </form>\n\n        {invited > 0 && (\n          <div className=\"px-2 pb-1 pt-3\">\n            <div className=\"flex items-baseline justify-between px-1 text-[12px] text-muted-foreground\">\n              <span>Invited</span>\n              <span className=\"tabular-nums\">\n                <NumberRoll value={invited} duration={600} />\n              </span>\n            </div>\n            <div className=\"relative mt-1\">\n              <div ref={ghostRef} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\" />\n              <ul ref={listRef} className=\"relative\">\n                {friends.map((f) => {\n                  const isIn = f.status === \"joined\";\n                  return (\n                    <li key={f.id} data-row-id={f.id} className=\"flex h-11 items-center gap-2.5 border-t border-border/70 px-1 first:border-t-0\">\n                      <span data-face aria-hidden=\"true\" className=\"flex size-7 shrink-0 items-center justify-center rounded-full text-[11px] font-medium\" style={{ ...tint(f.email), opacity: landing.has(f.id) ? 0.35 : 1, transition: \"opacity 200ms\" }}>\n                        {initials(f)}\n                      </span>\n                      <span className=\"min-w-0 flex-1 truncate text-[13.5px]\">\n                        {f.name ? (\n                          <>\n                            {f.name} <span className=\"text-muted-foreground\">{f.email}</span>\n                          </>\n                        ) : (\n                          f.email\n                        )}\n                      </span>\n                      <span className={`inline-flex shrink-0 items-center gap-1 text-[12px] ${isIn ? \"font-medium text-emerald-600 dark:text-emerald-400\" : \"text-muted-foreground\"}`} style={{ transition: \"color 300ms\" }}>\n                        <span className=\"grid\" style={{ gridTemplateColumns: isIn ? \"1fr\" : \"0fr\", transition: reduced ? \"none\" : `grid-template-columns 320ms ${EASE}` }}>\n                          <span className=\"min-w-0 overflow-hidden\">\n                            <Check aria-hidden=\"true\" className=\"size-3.5\" strokeWidth={2.5} />\n                          </span>\n                        </span>\n                        <TextMorph>{isIn ? \"Joined\" : \"Invited\"}</TextMorph>\n                      </span>\n                    </li>\n                  );\n                })}\n              </ul>\n            </div>\n          </div>\n        )}\n\n        <span className=\"sr-only\" role=\"status\">\n          {said}\n        </span>\n      </div>\n    </section>\n  );\n}\n\nexport default Referral;\n"},{"path":"registry/ui-interaction-motion.ts","type":"registry:component","content":"// Hairline UI · referral · 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"},{"path":"registry/use-row-motion.ts","type":"registry:component","content":"// Hairline UI · referral · 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 * ROW MOTION: rows that keep their place on screen\n *\n *   glide     after any render (a sort, a filter, new data from\n *             the server) every row that moved glides from its\n *             old place to its new one. Glides are additive, so\n *             a change that lands mid-glide adds its own offset\n *             instead of restarting anything\n *   travel    a row that sorts onto the page slides in from the\n *             side where it ranked before; one that sorts off\n *             slides out toward its new rank\n *   enter     a row that's new to the list fades in where it\n *             lands while the rows below glide clear of it\n *   leave     a row that's gone folds away where it stood. Its\n *             copy is the row's own element, detached by React\n *             and moved into a layer React never renders into,\n *             so a leaving row costs no render at all\n *   stagger   a sort can ask for the rows to set off one after\n *             another, top first\n *\n * Positions are layout offsets (transforms ignored), kept after\n * every render and whenever the list changes size (a row\n * opening its detail), so they're never stale. Rows are found\n * by data-row-id; ranks are their places in the whole sorted\n * list, not just the page.\n * ───────────────────────────────────────────────────────── */\n\ntype Seen = { top: number; height: number; el: HTMLElement };\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst GLIDE = 480;\n\nexport function useRowMotion(\n  container: React.RefObject<HTMLElement | null>,\n  { reduced, resetKey, ranks, ghosts }: { reduced: boolean; resetKey?: string | number; ranks: ReadonlyMap<string, number>; ghosts: React.RefObject<HTMLElement | null> },\n) {\n  const seen = React.useRef(new Map<string, Seen>());\n  const lastRanks = React.useRef(ranks);\n  const lastKey = React.useRef(resetKey);\n  const staggerNext = React.useRef(false);\n\n  const read = React.useCallback(() => {\n    const out = new Map<string, Seen>();\n    container.current?.querySelectorAll<HTMLElement>(\"[data-row-id]\").forEach((el) => out.set(el.dataset.rowId!, { top: el.offsetTop, height: el.offsetHeight, el }));\n    return out;\n  }, [container]);\n\n  /** The row React just removed, back on screen where it stood, leaving: sliding toward where it went, or folding. */\n  const leave = (was: Seen, dy: number) => {\n    const layer = ghosts.current;\n    const el = was.el;\n    if (!layer || el.isConnected) return;\n    // No getAnimations() here (it flushes style for the whole page): the exit below is added last and replaces any\n    // glide still on the element.\n    el.removeAttribute(\"data-row-id\");\n    el.removeAttribute(\"tabindex\");\n    el.setAttribute(\"aria-hidden\", \"true\");\n    el.inert = true;\n    Object.assign(el.style, { position: \"absolute\", left: \"0\", right: \"0\", top: `${was.top}px`, height: `${was.height}px`, margin: \"0\", overflow: \"hidden\" });\n    layer.appendChild(el);\n    const a = el.animate(\n      dy\n        ? [\n            { transform: \"translateY(0px)\", opacity: 1, filter: \"blur(0px)\" },\n            { transform: `translateY(${dy}px)`, opacity: 0, filter: \"blur(3px)\" },\n          ]\n        : [\n            { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n            { opacity: 0, transform: \"scale(0.985)\", filter: \"blur(4px)\" },\n          ],\n      { duration: dy ? 360 : 280, easing: EASE, fill: \"forwards\" },\n    );\n    a.onfinish = a.oncancel = () => el.remove();\n  };\n\n  React.useLayoutEffect(() => {\n    const el = container.current;\n    if (!el) return;\n    const before = seen.current;\n    const after = read();\n    const prevRanks = lastRanks.current;\n    seen.current = after;\n    lastRanks.current = ranks;\n    const stagger = staggerNext.current;\n    staggerNext.current = false;\n    if (lastKey.current !== resetKey) {\n      // A different page: the caller slides the whole body; nothing glides and nothing folds.\n      lastKey.current = resetKey;\n      return;\n    }\n    if (reduced || !before.size) return;\n\n    // A typical row's height. Rows that come onto or leave the page only hint at the way they went (a row and a\n    // quarter at most), so the list never shows holes while they travel.\n    const heights = [...after.values()].map((b) => b.height).sort((a, b) => a - b);\n    const rowH = heights[Math.floor(heights.length / 2)] ?? 48;\n    const clamp = (dy: number) => Math.max(-rowH * 1.25, Math.min(rowH * 1.25, dy));\n\n    let order = 0;\n    after.forEach((now, id) => {\n      const row = now.el;\n      const was = before.get(id);\n      const delay = stagger ? Math.min(order++ * 16, 200) : 0;\n      if (was) {\n        const dy = was.top - now.top;\n        if (Math.abs(dy) < 0.5) return;\n        row.animate([{ transform: `translateY(${dy}px)`, zIndex: 1 }, { transform: \"translateY(0px)\", zIndex: 1 }], { duration: GLIDE, delay, easing: EASE, composite: \"add\", fill: \"backwards\" });\n        return;\n      }\n      const from = prevRanks.get(id);\n      const to = ranks.get(id);\n      if (from !== undefined && to !== undefined) {\n        // It ranked somewhere off the page: it comes in from that side.\n        row.animate(\n          [\n            { transform: `translateY(${clamp((from - to) * rowH)}px)`, opacity: 0, filter: \"blur(3px)\" },\n            { transform: \"translateY(0px)\", opacity: 1, filter: \"blur(0px)\" },\n          ],\n          { duration: GLIDE, delay: delay / 2, easing: EASE, fill: \"backwards\" },\n        );\n        return;\n      }\n      row.animate(\n        [\n          { opacity: 0, filter: \"blur(4px)\" },\n          { opacity: 1, filter: \"blur(0px)\" },\n        ],\n        { duration: 420, delay: 90, easing: EASE, fill: \"backwards\" },\n      );\n    });\n\n    // Still in the list, just not on this page: its copy slides toward where it went. Gone for good: it folds.\n    before.forEach((was, id) => {\n      if (after.has(id)) return;\n      const from = prevRanks.get(id);\n      const to = ranks.get(id);\n      leave(was, from !== undefined && to !== undefined ? clamp((to - from) * rowH) || rowH : 0);\n    });\n  });\n\n  // The list changed size without a render of ours (a detail opening by transition): keep the positions true.\n  React.useEffect(() => {\n    const el = container.current;\n    if (!el) return;\n    const observer = new ResizeObserver(() => {\n      seen.current = read();\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [container, read]);\n\n  return {\n    /** The next change (a sort) sets the rows off one after another. */\n    stagger: () => {\n      staggerNext.current = true;\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/referral. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}