Thumbs that nod when pressed; a thumbs down unfolds reasons, a comment and send.
01Preview
Your Pro plan renews on 12 October for $240. Annual billing saves $48 against paying monthly, and seats you add mid-cycle are prorated to the renewal date.
Was this helpful?
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install @base-ui/react motion03Usage
import { ResponseFeedback } from "@/components/ui/response-feedback";
<ResponseFeedback
onValueChange={(rating) => saveRating(message.id, rating)}
onSubmit={(details) => api.feedback(message.id, details)}
/>04Source
"use client";
import { Toggle } from "@base-ui/react/toggle";
import { ToggleGroup } from "@base-ui/react/toggle-group";
import { AnimatePresence, motion, useAnimate, useReducedMotion } from "motion/react";
import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
import { cn } from "@/lib/cn";
import { X } from "@/lib/icons";
import { ease, spring, stagger } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";
export type FeedbackRating = "up" | "down" | null;
export type FeedbackDetails = { rating: "down"; reasons: string[]; comment: string };
export type ResponseFeedbackProps = Omit<React.ComponentProps<"div">, "defaultValue" | "onSubmit"> & {
value?: FeedbackRating;
defaultValue?: FeedbackRating;
/** Fires the moment a thumb is pressed or released: save the rating here. */
onValueChange?: (value: FeedbackRating) => void;
/** Reasons offered after a thumbs down. An empty list keeps just the comment. */
reasons?: string[];
/** Sends the details. Return a promise to show progress; a rejection keeps the form and says so. */
onSubmit?: (details: FeedbackDetails) => void | Promise<void>;
label?: string;
disabled?: boolean;
};
type Phase = "closed" | "open" | "sending" | "sent" | "failed";
const DEFAULT_REASONS = ["Not accurate", "Didn’t follow instructions", "Out of date", "Too long", "Missing sources", "Unsafe"];
const UP =
"M5 7.25v6H3a.75.75 0 0 1-.75-.75V8A.75.75 0 0 1 3 7.25zm0 0 2.5-4.5a1.4 1.4 0 0 1 1.9 1.3l-.4 2.2h3.3a1.25 1.25 0 0 1 1.2 1.6l-1.3 4.3a1.25 1.25 0 0 1-1.2.85H5";
const DOWN =
"M11 8.75v-6h2a.75.75 0 0 1 .75.75V8a.75.75 0 0 1-.75.75zm0 0-2.5 4.5a1.4 1.4 0 0 1-1.9-1.3l.4-2.2H3.7a1.25 1.25 0 0 1-1.2-1.6l1.3-4.3a1.25 1.25 0 0 1 1.2-.85H11";
const noop = () => () => {};
/** ⌘ on Apple platforms, Ctrl elsewhere; ⌘ on the server so the first paint matches most readers. */
const useModKey = () => useSyncExternalStore(noop, () => (/Mac|iPhone|iPad/.test(navigator.platform) ? "⌘" : "Ctrl"), () => "⌘");
const ring = "outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3";
export function ResponseFeedback({
value,
defaultValue = null,
onValueChange,
reasons = DEFAULT_REASONS,
onSubmit,
label = "Was this helpful?",
disabled = false,
className,
...rest
}: ResponseFeedbackProps) {
const reduce = !!useReducedMotion();
const id = useId();
const mod = useModKey();
const [rating, setRating] = useControllableState<FeedbackRating>({ value, defaultValue, onChange: onValueChange });
const [phase, setPhase] = useState<Phase>("closed");
const [picked, setPicked] = useState<string[]>([]);
const [comment, setComment] = useState("");
const downRef = useRef<HTMLButtonElement>(null);
const card = useRef<HTMLFormElement>(null);
const keyboard = useRef(false);
const focusOnOpen = useRef(false);
const closeTimer = useRef<number>(undefined);
useEffect(() => () => window.clearTimeout(closeTimer.current), []);
const open = phase !== "closed";
const empty = picked.length === 0 && comment.trim() === "";
const focus = (el: HTMLElement | null | undefined) => el?.focus({ preventScroll: true, focusVisible: keyboard.current } as FocusOptions);
// The card's first reason gets focus once it exists, so the keyboard lands where the next decision is.
useEffect(() => {
if (phase !== "open" || !focusOnOpen.current) return;
// One frame later, once the reason group has registered its items, so arrow keys pick up from here.
// A render in between cancels and reschedules it; the flag only clears once it lands.
const raf = requestAnimationFrame(() => {
focusOnOpen.current = false;
focus(card.current?.querySelector<HTMLElement>("[data-reason], textarea"));
});
return () => cancelAnimationFrame(raf);
});
const close = (returnFocus: boolean) => {
window.clearTimeout(closeTimer.current);
setPhase("closed");
if (returnFocus) focus(downRef.current);
};
const rate = (next: FeedbackRating) => {
window.clearTimeout(closeTimer.current);
setRating(next);
if (next === "down") {
setPicked([]);
setComment("");
focusOnOpen.current = true;
setPhase("open");
} else setPhase("closed");
};
const send = async () => {
if (empty || phase === "sending") return;
setPhase("sending");
const started = performance.now();
try {
await onSubmit?.({ rating: "down", reasons: picked, comment: comment.trim() });
// A spinner that flashes for a frame reads as a glitch; hold it for 300ms.
await new Promise((r) => window.setTimeout(r, Math.max(0, 300 - (performance.now() - started))));
setPhase("sent");
focus(downRef.current);
closeTimer.current = window.setTimeout(() => setPhase("closed"), 2600);
} catch {
setPhase("failed");
}
};
const status = rating === "up" ? "Thanks for letting us know" : phase === "sent" || (rating === "down" && phase === "closed") ? "Thanks, feedback noted" : label;
const labels = [label, "Thanks for letting us know", "Thanks, feedback noted"];
return (
<div className={cn("flex w-full flex-col", className)} {...rest}>
<div className="flex items-center gap-2">
<ToggleGroup
aria-label="Rate this response"
value={rating ? [rating] : []}
onValueChange={(v) => rate((v[0] as FeedbackRating) ?? null)}
disabled={disabled}
className="-ml-1 flex items-center gap-0.5"
>
<Thumb dir="up" pressed={rating === "up"} reduce={reduce} onPointerDown={() => (keyboard.current = false)} onKeyDown={() => (keyboard.current = true)} />
<Thumb
dir="down"
ref={downRef}
pressed={rating === "down"}
reduce={reduce}
expanded={open}
controls={`${id}-card`}
onPointerDown={() => (keyboard.current = false)}
onKeyDown={() => (keyboard.current = true)}
/>
</ToggleGroup>
{/* Every message shares one grid cell, so the row never reflows as it changes. */}
<span className="grid min-w-0 text-[12.5px] text-fg-3">
{labels.map((l) => (
<span key={l} aria-hidden className="invisible col-start-1 row-start-1 truncate">
{l}
</span>
))}
<AnimatePresence initial={false}>
<motion.span
key={status}
className="col-start-1 row-start-1 truncate"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 5, filter: "blur(2px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -5, filter: "blur(2px)", transition: { duration: 0.12 } }}
transition={{ duration: 0.22, ease: ease.out }}
>
{status}
</motion.span>
</AnimatePresence>
</span>
<span role="status" aria-live="polite" className="sr-only">
{rating === "up" ? "Marked as helpful" : phase === "sent" ? "Feedback sent" : phase === "failed" ? "Couldn’t send feedback" : ""}
</span>
</div>
<AnimatePresence initial={false}>
{open && (
<motion.div
key="card"
initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={
reduce
? { opacity: 0, transition: { duration: 0.12 } }
: { height: 0, opacity: 0, transition: { height: { duration: 0.22, ease: ease.inOut }, opacity: { duration: 0.12 } } }
}
transition={{ height: { duration: 0.3, ease: ease.out }, opacity: { duration: 0.2, ease: ease.out } }}
// Padding inside the clip, cancelled by margin, so the card's shadow and focus rings aren't cut off.
className="-mx-3 -mb-5 overflow-hidden px-3 pb-5"
>
{/* The card grows down from the row; its content settles a beat later so the height leads. */}
<form
ref={card}
id={`${id}-card`}
aria-labelledby={`${id}-title`}
onSubmit={(e) => {
e.preventDefault();
void send();
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
keyboard.current = true;
close(true);
}
}}
className="mt-2 max-w-md rounded-xl border border-line-2 bg-raised shadow-[var(--shadow)]"
>
<Morph reduce={reduce}>
<AnimatePresence initial={false} mode="wait">
{phase === "sent" ? (
<motion.div
key="sent"
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.98, filter: "blur(2px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
transition={{ duration: 0.24, ease: ease.out }}
className="flex items-center gap-2.5 px-3.5 py-3"
>
<SentMark reduce={reduce} />
<div className="min-w-0">
<p id={`${id}-title`} className="text-[13px] font-medium tracking-[-0.01em] text-fg">
Feedback sent
</p>
<p className="text-[12px] text-fg-3">It goes to the team that tunes these answers.</p>
</div>
</motion.div>
) : (
<motion.div
key="form"
initial={false}
exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, scale: 0.99, filter: "blur(2px)", transition: { duration: 0.12 } }}
className="p-3.5"
aria-busy={phase === "sending" || undefined}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p id={`${id}-title`} className="text-[13px] font-medium tracking-[-0.01em] text-fg">
What went wrong?
</p>
<p className="mt-0.5 text-[12px] text-fg-3">Pick any that apply.</p>
</div>
<button
type="button"
aria-label="Skip feedback"
onClick={(e) => {
keyboard.current = e.detail === 0;
close(true);
}}
className={cn(
ring,
"relative -mr-1.5 -mt-1.5 grid size-7 shrink-0 place-items-center rounded-md text-fg-3 transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.92]",
"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden",
)}
>
<X size={14} />
</button>
</div>
{reasons.length > 0 && (
<ToggleGroup
multiple
aria-label="Reasons"
value={picked}
onValueChange={(v) => setPicked(v as string[])}
disabled={phase === "sending"}
className="mt-3 flex flex-wrap gap-1.5"
>
{reasons.map((r, i) => (
<motion.span
key={r}
className="flex"
initial={reduce ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, ease: ease.out, delay: 0.08 + Math.min(i, 8) * stagger.items }}
>
<Reason value={r} pressed={picked.includes(r)} reduce={reduce} />
</motion.span>
))}
</ToggleGroup>
)}
<label htmlFor={`${id}-comment`} className="mt-3 block text-[12px] text-fg-2">
Anything else? <span className="text-fg-4">Optional</span>
</label>
<textarea
id={`${id}-comment`}
value={comment}
onChange={(e) => setComment(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
void send();
}
}}
readOnly={phase === "sending"}
rows={2}
maxLength={1000}
placeholder="The date it gave for the launch was last year’s"
className={cn(
"mt-1.5 block max-h-32 min-h-[60px] w-full resize-none rounded-lg border border-line-2 bg-frame px-2.5 py-2 text-base leading-[1.45] text-fg [field-sizing:content] placeholder:text-fg-4 sm:text-[13px]",
"outline-none transition-[border-color,box-shadow] duration-150 focus:border-fg-4 focus:ring-3 focus:ring-fg/10",
)}
/>
<div className="mt-3 flex items-center gap-2">
<span className="min-w-0 flex-1 text-[11.5px]">
{phase === "failed" ? (
<span className="text-danger">Couldn’t send. Check your connection and try again.</span>
) : (
<span className="hidden text-fg-4 pointer-fine:inline">
<kbd className="font-sans">{mod}</kbd> <kbd className="font-sans">Enter</kbd> to send
</span>
)}
</span>
<button
type="button"
onClick={(e) => {
keyboard.current = e.detail === 0;
close(true);
}}
className={cn(
ring,
"h-8 shrink-0 rounded-lg px-2.5 text-[12.5px] font-medium text-fg-2 transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.97]",
)}
>
Skip
</button>
<button
type="submit"
aria-disabled={empty || undefined}
data-busy={phase === "sending" ? "" : undefined}
className={cn(
ring,
"relative h-8 shrink-0 rounded-lg bg-fg px-3 text-[12.5px] font-medium text-frame transition-[background-color,opacity,scale] duration-150 hover:bg-fg/90 active:scale-[0.97]",
"aria-disabled:cursor-not-allowed aria-disabled:opacity-40 aria-disabled:hover:bg-fg aria-disabled:active:scale-100",
"data-busy:pointer-events-none",
)}
>
{/* The spinner overlays the label, so the button keeps its width. */}
<span className={cn("transition-opacity duration-150", phase === "sending" && "opacity-0")}>
{phase === "failed" ? "Try again" : "Send feedback"}
</span>
<span
className={cn("absolute inset-0 grid place-items-center transition-opacity duration-150", phase === "sending" ? "opacity-100" : "opacity-0")}
aria-hidden
>
<svg width={14} height={14} viewBox="0 0 16 16" fill="none" className="animate-spin-slow motion-reduce:animate-none">
<circle cx="8" cy="8" r="5.5" stroke="currentColor" strokeOpacity={0.3} strokeWidth={1.6} />
<path d="M8 2.5a5.5 5.5 0 0 1 5.5 5.5" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" />
</svg>
</span>
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</Morph>
</form>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
/** Animates its own height to whatever its content measures, so swapping the form for the thanks glides instead of jumping. */
function Morph({ reduce, children }: { reduce: boolean; children: React.ReactNode }) {
const inner = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState<number | "auto">("auto");
useEffect(() => {
const el = inner.current;
if (!el) return;
const ro = new ResizeObserver(() => setHeight(el.offsetHeight));
ro.observe(el);
return () => ro.disconnect();
}, []);
return (
<motion.div initial={false} animate={{ height }} transition={reduce ? { duration: 0 } : { duration: 0.28, ease: ease.inOut }} className="overflow-hidden">
<div ref={inner}>{children}</div>
</motion.div>
);
}
function Thumb({
dir,
pressed,
reduce,
expanded,
controls,
ref,
...rest
}: {
dir: "up" | "down";
pressed: boolean;
reduce: boolean;
expanded?: boolean;
controls?: string;
ref?: React.Ref<HTMLButtonElement>;
onPointerDown?: () => void;
onKeyDown?: () => void;
}) {
const [scope, animate] = useAnimate<HTMLSpanElement>();
const name = dir === "up" ? "Helpful" : "Not helpful";
// The acknowledgement: a small nod in the thumb's own direction as it fills.
const mounted = useRef(false);
useEffect(() => {
// Only a change nods; a thumb that starts pressed just sits there.
if (!mounted.current) return void (mounted.current = true);
if (!pressed || reduce || !scope.current) return;
animate(scope.current, { rotate: [0, dir === "up" ? -16 : 16, 0], y: [0, dir === "up" ? -2 : 2, 0], scale: [1, 0.88, 1] }, { duration: 0.44, ease: ease.out });
}, [pressed, reduce, dir, animate, scope]);
return (
<Toggle
ref={ref}
value={dir}
aria-label={name}
title={name}
aria-expanded={dir === "down" ? !!expanded : undefined}
aria-controls={dir === "down" && expanded ? controls : undefined}
className={cn(
ring,
"relative grid size-7 place-items-center rounded-md text-fg-3 transition-[background-color,color,scale] duration-150 ease-out",
"hover:bg-hover hover:text-fg active:scale-[0.9] active:duration-75 data-pressed:text-fg data-disabled:pointer-events-none data-disabled:opacity-50",
"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden",
)}
{...rest}
>
<span ref={scope} className="grid size-4 place-items-center">
<svg width={16} height={16} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.4} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<motion.path
d={dir === "up" ? UP : DOWN}
fill="currentColor"
initial={false}
animate={{ fillOpacity: pressed ? 1 : 0 }}
transition={{ duration: pressed ? 0.2 : 0.14, ease: ease.out, delay: pressed && !reduce ? 0.08 : 0 }}
/>
</svg>
</span>
</Toggle>
);
}
function Reason({ value, pressed, reduce }: { value: string; pressed: boolean; reduce: boolean }) {
return (
<Toggle
value={value}
data-reason=""
className={cn(
ring,
"relative inline-flex h-7 select-none items-center rounded-full border px-2.5 text-[12px] transition-[background-color,border-color,color,scale] duration-150 ease-out active:scale-[0.96] active:duration-75",
"border-line-2 text-fg-2 hover:border-fg-4 hover:text-fg data-pressed:border-fg-4 data-pressed:bg-fg/[0.07] data-pressed:text-fg data-disabled:opacity-50",
)}
>
{/* The tick makes room for itself: its column grows from nothing while it draws. */}
<span
className={cn(
"grid transition-[grid-template-columns] duration-200 ease-out-expo motion-reduce:transition-none",
pressed ? "grid-cols-[1fr]" : "grid-cols-[0fr]",
)}
>
<span className="overflow-hidden">
<svg
width={12}
height={12}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className="-ml-0.5 mr-1"
>
<motion.path
d="M3.5 8.5 6.5 11.5 12.5 4.5"
initial={false}
animate={{ pathLength: pressed ? 1 : 0, opacity: pressed ? 1 : 0 }}
transition={{ duration: reduce ? 0 : 0.26, ease: ease.out, delay: pressed && !reduce ? 0.06 : 0 }}
/>
</svg>
</span>
</span>
{value}
</Toggle>
);
}
function SentMark({ reduce }: { reduce: boolean }) {
return (
<motion.span
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
transition={reduce ? { duration: 0.15 } : spring.pop}
className="grid size-7 shrink-0 place-items-center rounded-full bg-success-soft text-success"
>
<svg width={14} height={14} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<motion.path
d="M3.5 8.5 6.5 11.5 12.5 4.5"
initial={reduce ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.34, ease: ease.out, delay: 0.1 }}
/>
</svg>
</motion.span>
);
}05Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | "up" | "down" | null | — | The rating. Controlled. |
| defaultValue | "up" | "down" | null | null | Uncontrolled starting rating. A thumb that starts pressed doesn't nod. |
| onValueChange | (value) => void | — | Fires the moment a thumb is pressed or released. Save the rating here; the details come separately. |
| reasons | string[] | 6 common reasons | Chips offered after a thumbs down. An empty list leaves just the comment. |
| onSubmit | (details: { rating: "down"; reasons: string[]; comment: string }) => void | Promise<void> | — | Sends the details. Return a promise to show progress; a rejection keeps the form filled and says so. |
| label | string | "Was this helpful?" | The prompt beside the thumbs. |
| disabled | boolean | false | Disables the thumbs, e.g. while the response is still streaming. |
06Notes
Behavior
- Thumbs up is the whole interaction: it fills, nods and the prompt becomes Thanks for letting us know. Nothing opens.
- Thumbs down records the rating straight away, then offers the card. Skip, the ×, or Escape closes it and keeps the rating; pressing the thumb again clears both.
- Send stays inactive until a reason is picked or something is typed. Sending holds its spinner for at least 300ms; a failure keeps the reasons and comment, says so beside the button, and the button becomes Try again.
- Sent swaps the form for a short confirmation, the card's height gliding between them, then the card folds away after 2.6s and the prompt keeps saying Thanks, feedback noted.
- The comment grows with its text up to 8 lines, allows 1,000 characters, and sends with ⌘ Enter (Ctrl Enter off Apple devices).
Motion
- A pressed thumb nods in its own direction (16° and 2px, dipping to 0.88 scale) over 440ms while its fill arrives 80ms later; releasing just empties it.
- The card grows to height over 300ms ease-out while the chips rise 4px, 20ms apart. It folds away in 220ms on the in-out curve.
- A chosen chip makes room for its tick by growing a grid column over 200ms while the tick draws; the prompt swaps by sliding 5px with a 2px blur inside a fixed-width cell.
- Reduced motion drops the nod, the travel, the blur and the height glides, keeping short fades; the tick simply appears.
Accessibility
- The thumbs are a toggle group (Helpful, Not helpful) with arrow keys between them; Not helpful carries aria-expanded and aria-controls for the card.
- The card is a labelled form. It takes focus on its first reason when it opens (without a ring after a pointer press), and Escape or Skip returns focus to the thumb.
- Reasons are a multi-select toggle group with arrow-key roving and pressed state. A polite live region announces Marked as helpful, Feedback sent, or the failure.