Components

Getting started

Blocks9

Components154

More

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.

Used forNPS surveysAfter an orderIn-app feedbackSupport follow-ups

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/feedback.json

Templates

Need the whole page?

43 Next.js landing pages, each designed from scratch. Try any of them live; one is $39, all of them $99, and 5 are free.

All 43 templates

Where it’s used

  • NPS surveys

    The 0–10 question in the app or an email landing page, with a follow-up that fits.

  • After an order

    Stars for a delivery or a purchase, with tags for what went right or wrong.

  • In-app feedback

    “How was this?” after a feature, a report or an AI answer.

  • Support follow-ups

    Rating a resolved ticket, with the reason when it went badly.

What it handles for you

A survey widget is usually a number picker, a text box and a thank-you message with no link between the score and what you ask next. This block asks the right follow-up for the score and sends it anywhere you like.

Instead of wiring upan NPS scalea star ratingfollow-up questionsfeedback tagsa comment boxa thank-you state
  1. Two scales

    scale="nps" for 0–10, or "stars" for one to five with a word for each (Poor to Great).

  2. A question for the score

    Low, middle and high scores each get their own question and tags; set prompts to word them for your product.

  3. The score you can feel

    The thumb is thrown to it, the number rolls into the chip and the light follows; hovering previews before you commit.

  4. Keyboard first

    Arrow keys move the score and number keys jump straight to it; tags are toggle buttons.

  5. Sent in place

    onSubmit gets { score, band, tags, comment }; Send goes Sending → Sent and the form turns into the thanks.

  6. Anywhere it’s shown

    Compact enough for a card, a corner or an email landing page; onDismiss adds a close button.

Overview

Most feedback widgets ask for a number and then the same “Any comments?” whether you gave a 2 or a 10, so the answers say little. Here the question and its scale sit on a lit panel, and a band of light along its top follows the score you’re on. Pick one and the thumb is thrown to it while the number rolls into the chip beside the question. The follow-up opens under it and fits the score: “What went wrong?” for a 3, “What would make it a 10?” for a 7, “What do you love most?” for a 9, each with quick tags that come in from the side the score moved. Send goes Sending → Sent and the follow-up turns into the thanks with what you said. Where it goes is yours: onSubmit gets the score, its band, the tags and the comment.

States

ask
The lit panel with the question, a “?” chip and the scale in an inset track; the ends are labelled under it.
hover
With a mouse, the thumb previews the score you’re over at half strength and the chip rolls to it; leaving puts both back.
score
The thumb is thrown to the score (460ms with a slight overshoot), the chip’s number rolls, and the light along the panel’s top glides over it (620ms). Stars fill one after another (40ms apart) and the word under them morphs.
follow-up
The first score opens the follow-up (460ms); a score in another band clears the tags and brings the new question and tags in from the side the score moved, 35ms apart.
sending
Send feedback → Sending; tags and the comment are locked.
thanks
A check draws itself in the button, then the form turns into “Thanks, that helps.” with the score and tags, and the scale is locked.
error
If onSubmit throws, the button shakes into Try again and your message shows under it; nothing is lost.

Usage

usage.tsx
import { Feedback } from "@/components/feedback";<Feedback  question="How likely are you to recommend Relay to a friend?"  onSubmit={(answer) => fetch("/api/feedback", { method: "POST", body: JSON.stringify(answer) })}/>

Depends on lucide-react, installed for you by the CLI along with the keyframes it needs.

Works with

With your own endpoint

Post the answer to a route that stores it (a table, a sheet, your analytics). Word the follow-ups for your product with prompts.

example.tsx
// app/api/feedback/route.tsexport async function POST(req: Request) {  const { score, band, tags, comment } = await req.json();  await db.feedback.insert({ score, band, tags, comment, at: new Date() });  return Response.json({ ok: true });}// components/how-was-it.tsx"use client";import { Feedback } from "@/components/feedback";export function HowWasIt() {  return (    <Feedback      scale="stars"      question="How was your order?"      prompts={{        low: { question: "What went wrong?", tags: ["Arrived late", "Damaged", "Wrong item", "Not as described"] },        high: { question: "What did you like?", tags: ["Fast delivery", "Packaging", "Quality"] },      }}      onSubmit={async (answer) => {        const res = await fetch("/api/feedback", { method: "POST", body: JSON.stringify(answer) });        if (!res.ok) throw new Error("Couldn’t send that. Try again.");      }}    />  );}

Props

PropTypeDescription
onSubmit(answer: FeedbackAnswer) => Promise<unknown>Gets { score, band: "low" | "mid" | "high", tags, comment }; throw an Error to show its message.
questionstringdefault "How likely are you to recommend us to a friend?"The question on the panel.
scale"nps" | "stars"default "nps"0–10 (0–6 low, 7–8 mid, 9–10 high), or one to five stars (1–2 low, 3 mid, 4–5 high).
promptsPartial<Record<band, { question, tags }>>The follow-up question and quick tags for each band.
ends[string, string]default "Not likely", "Very likely" / "Poor", "Great"Words under each end of the scale.
onDismiss() => voidShows a close button on the panel.

Accessibility and motion

  • The scale is a radio group named by the question: arrows and Home/End move it, number keys jump to a score, and each star is named (“4 stars, Good”).
  • The chip and the light are decoration; the checked radio is the score. Tags are toggle buttons with aria-pressed.
  • The thanks is a status region, so sending is announced once; an error is an alert under the button.
  • Hover previews only follow a real mouse, so a tap on a phone never leaves a preview behind.
  • Its light comes from your theme: the panel is --primary with a band of --primary-foreground that follows the score, the track an inset 1px edge. No drop shadows, glow or blur.
  • Built from Textarea, Number roll, Status button and Text morph, which the CLI installs with it. No animation library; with reduced motion the score, stars and follow-up change in place.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs Number roll, Status button, Text morph, Textarea.

Source

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

Registry item: /r/feedback.json