"use client";
import * as React from "react";
import { AnimatePresence, animate, motion, useMotionValue, useTransform, type MotionValue } from "motion/react";
export interface FeedbackSubmission {
/** 1 (most negative) to 5 (most positive). */
score: number;
/** The step's label, e.g. "Happy". */
label: string;
comment: string;
}
export interface FeedbackScaleProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "onSubmit" | "defaultValue"> {
/** Controlled score (1–5), or null for no answer. */
value?: number | null;
/** Initial score when uncontrolled. */
defaultValue?: number | null;
onChange?: (score: number) => void;
/** Called with the score, its label and the comment when the form is sent. */
onSubmit?: (feedback: FeedbackSubmission) => void;
/** The question; also the radiogroup's accessible name. */
question?: string;
/** Five labels, most negative first. */
labels?: [string, string, string, string, string];
/** Show each step's label under its face. */
showLabels?: boolean;
/** Large face beside the question that morphs towards the hovered or chosen step. */
showPreview?: boolean;
/** Reveal a comment field once a score is chosen. */
showComment?: boolean;
/** Label of the comment field. */
commentLabel?: string;
/** Text of the submit button. */
submitLabel?: string;
/** Message on the thank-you screen. */
thanksMessage?: string;
/** Face size. */
size?: "sm" | "md" | "lg";
/** Spring for the face morph (ignored with reduced motion). */
spring?: { stiffness: number; damping: number; mass: number };
/** Focus rings and the submit button. */
accent?: string;
/** Name for the radios and the textarea, so the answer submits with a surrounding form. */
name?: string;
}
/* --------------------------- reduced-motion hook --------------------------- */
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* ---------------------------------- faces ---------------------------------- */
// Warm-negative to positive. Dark facial features stay legible on every one of these.
const STEP_COLORS = ["#EF5A4C", "#F58A4B", "#F2C14E", "#9BCB63", "#3DBE84"];
const STOPS = [0, 0.25, 0.5, 0.75, 1];
const DEFAULT_LABELS: [string, string, string, string, string] = ["Very unhappy", "Unhappy", "Neutral", "Happy", "Very happy"];
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const clamp01 = (t: number) => Math.min(1, Math.max(0, t));
const r2 = (n: number) => Math.round(n * 100) / 100;
/*
* Every expression is a function of one number, mood m ∈ [0, 1] (0 = very unhappy).
* The face is drawn in a 48×48 box; paths keep the same commands for every m, so they interpolate.
*/
function mouthPath(m: number) {
const width = lerp(14, 20, m);
const y = lerp(33, 30.5, m);
const curve = lerp(-6.5, 7, m);
const open = clamp01((m - 0.7) / 0.3) * 5.5; // the widest smile opens
const x1 = r2(24 - width / 2);
const x2 = r2(24 + width / 2);
return `M${x1} ${r2(y)} Q24 ${r2(y + curve - open * 0.5)} ${x2} ${r2(y)} Q24 ${r2(y + curve + open)} ${x1} ${r2(y)} Z`;
}
function eyeRy(m: number) {
// Happy faces squint; very unhappy eyes droop a little.
return r2(3.1 - clamp01((m - 0.72) / 0.28) * 1.3 - clamp01((0.2 - m) / 0.2) * 0.4);
}
function browPath(m: number, side: -1 | 1) {
// Worry lifts the inner ends; neutral and happy faces have flat, faint brows.
const worry = clamp01((0.45 - m) / 0.45);
const outer = 24 + side * 11;
const inner = 24 + side * 4.5;
const y = 13.5 - clamp01((m - 0.75) / 0.25) * 1;
return `M${r2(outer)} ${r2(y + worry * 0.5)} L${r2(inner)} ${r2(y - worry * 3.2)}`;
}
const browOpacity = (m: number) => r2(0.25 + clamp01((0.45 - m) / 0.45) * 0.75);
const cheekOpacity = (m: number) => r2(clamp01((m - 0.6) / 0.4) * 0.45);
/** A static face for one step. */
function Face({ mood, className = "" }: { mood: number; className?: string }) {
return (
<svg viewBox="0 0 48 48" aria-hidden="true" className={className}>
<circle cx="24" cy="24" r="21.5" className="fill-[var(--fs-face)] stroke-[var(--fs-line)] transition-[fill,stroke] duration-200" strokeWidth="1.5" />
<circle cx="12.5" cy="29" r="3" style={{ fill: "var(--fs-cheek, #ffffff)" }} opacity={cheekOpacity(mood)} />
<circle cx="35.5" cy="29" r="3" style={{ fill: "var(--fs-cheek, #ffffff)" }} opacity={cheekOpacity(mood)} />
<g className="fill-[var(--fs-ink)] stroke-[var(--fs-ink)] transition-[fill,stroke] duration-200">
<path d={browPath(mood, -1)} fill="none" strokeWidth="1.8" strokeLinecap="round" opacity={browOpacity(mood)} />
<path d={browPath(mood, 1)} fill="none" strokeWidth="1.8" strokeLinecap="round" opacity={browOpacity(mood)} />
<ellipse cx="17.5" cy="20.5" rx="2.4" ry={eyeRy(mood)} stroke="none" />
<ellipse cx="30.5" cy="20.5" rx="2.4" ry={eyeRy(mood)} stroke="none" />
<path d={mouthPath(mood)} strokeWidth="1.9" strokeLinejoin="round" strokeLinecap="round" />
</g>
</svg>
);
}
/** The large face: every feature is a motion value derived from one animated mood. */
function MorphFace({ mood, idle, className = "" }: { mood: MotionValue<number>; idle: boolean; className?: string }) {
const fill = useTransform(mood, STOPS, STEP_COLORS);
const mouth = useTransform(mood, mouthPath);
const browL = useTransform(mood, (m) => browPath(m, -1));
const browR = useTransform(mood, (m) => browPath(m, 1));
const browO = useTransform(mood, browOpacity);
const eye = useTransform(mood, eyeRy);
const cheek = useTransform(mood, cheekOpacity);
return (
<svg
viewBox="0 0 48 48"
aria-hidden="true"
className={`transition-[filter,opacity] duration-300 motion-reduce:transition-none ${idle ? "opacity-70 grayscale" : ""} ${className}`}
>
<motion.circle cx="24" cy="24" r="22" style={{ fill }} />
<motion.circle cx="12.5" cy="29" r="3" fill="#ffffff" style={{ opacity: cheek }} />
<motion.circle cx="35.5" cy="29" r="3" fill="#ffffff" style={{ opacity: cheek }} />
<g fill="#18181b" stroke="#18181b">
<motion.path d={browL} fill="none" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: browO }} />
<motion.path d={browR} fill="none" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: browO }} />
<motion.ellipse cx="17.5" cy="20.5" rx="2.4" ry={eye} stroke="none" />
<motion.ellipse cx="30.5" cy="20.5" rx="2.4" ry={eye} stroke="none" />
<motion.path d={mouth} strokeWidth="1.9" strokeLinejoin="round" strokeLinecap="round" />
</g>
</svg>
);
}
const SIZES = {
sm: { face: "size-9", hero: "size-12", label: "text-[10px]" },
md: { face: "size-11", hero: "size-16", label: "text-[11px]" },
lg: { face: "size-14", hero: "size-20", label: "text-[12px]" },
};
const moodOf = (score: number) => (score - 1) / 4;
/* -------------------------------- component -------------------------------- */
/**
* A five-step satisfaction scale with drawn faces. A large face morphs towards the hovered or
* chosen step; choosing reveals an optional comment field, and sending shows a thank-you state.
* Built on native radios inside a radiogroup, so arrow keys and form submission come for free.
*/
export function FeedbackScale({
value,
defaultValue = null,
onChange,
onSubmit,
question = "How was your experience?",
labels = DEFAULT_LABELS,
showLabels = true,
showPreview = true,
showComment = true,
commentLabel = "Anything we could do better?",
submitLabel = "Send feedback",
thanksMessage = "Thanks — every response is read by the team.",
size = "md",
spring = { stiffness: 260, damping: 22, mass: 0.8 },
accent = "#FF7448",
name,
className = "",
style,
...props
}: FeedbackScaleProps) {
const reduceMotion = usePrefersReducedMotion();
const uid = React.useId();
const questionId = `${uid}-q`;
const commentId = `${uid}-comment`;
const groupName = name ?? `feedback-${uid}`;
// Set when "Edit response" is pressed: the form focuses the chosen face when it mounts again.
const refocusRef = React.useRef(false);
const [inner, setInner] = React.useState<number | null>(defaultValue);
const score = value !== undefined ? value : inner;
const [hover, setHover] = React.useState<number | null>(null);
const [comment, setComment] = React.useState("");
const [submitted, setSubmitted] = React.useState(false);
const dims = SIZES[size];
// The hero face follows hover first, then the chosen score, else rests on neutral.
const shown = hover ?? score;
const mood = useMotionValue(moodOf(shown ?? 3));
const target = moodOf(shown ?? 3);
const { stiffness, damping, mass } = spring;
React.useEffect(() => {
if (reduceMotion) {
mood.jump(target);
return;
}
const controls = animate(mood, target, { type: "spring", stiffness, damping, mass });
return () => controls.stop();
}, [mood, target, reduceMotion, stiffness, damping, mass]);
const choose = (next: number) => {
if (value === undefined) setInner(next);
onChange?.(next);
};
const submit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (score == null) return;
onSubmit?.({ score, label: labels[score - 1], comment: comment.trim() });
setSubmitted(true);
};
const edit = () => {
refocusRef.current = true;
setSubmitted(false);
};
// The two states swap with an exit animation, so focus is moved when the new one mounts.
const focusThanks = (el: HTMLHeadingElement | null) => el?.focus({ preventScroll: true });
const focusChosen = (el: HTMLFormElement | null) => {
if (!el || !refocusRef.current) return;
refocusRef.current = false;
el.querySelector<HTMLInputElement>("input[type=radio]:checked")?.focus({ preventScroll: true });
};
const fade = reduceMotion
? { initial: false as const, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 0 } }
: {
initial: { opacity: 0, y: 8 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -8 },
transition: { type: "spring" as const, stiffness: 320, damping: 30 },
};
return (
<div
className={`w-full max-w-md rounded-2xl border border-zinc-900/10 bg-white p-5 text-zinc-950 shadow-[0_1px_2px_rgb(0_0_0/0.04),0_12px_32px_-16px_rgb(0_0_0/0.18)] sm:p-6 dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-50 dark:shadow-none ${className}`}
style={{ ["--fs-accent" as string]: accent, ...style }}
{...props}
>
<AnimatePresence mode="wait" initial={false}>
{submitted && score != null ? (
<motion.div key="thanks" {...fade} role="status" className="flex flex-col items-center gap-3 py-4 text-center">
<div className="relative">
<motion.div
initial={reduceMotion ? false : { scale: 0.6, rotate: -8 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 380, damping: 14 }}
className="[--fs-ink:#18181b] [--fs-line:transparent]"
style={{ ["--fs-face" as string]: STEP_COLORS[score - 1] }}
>
<Face mood={moodOf(score)} className="size-16" />
</motion.div>
<svg viewBox="0 0 24 24" aria-hidden="true" className="absolute -right-1.5 -bottom-1 size-7 rounded-full bg-zinc-950 p-1 text-white ring-2 ring-white dark:bg-white dark:text-zinc-950 dark:ring-zinc-900">
<motion.path
d="M6 12.5l4 4 8-9"
fill="none"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
initial={reduceMotion ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.45, delay: 0.15, ease: [0.65, 0, 0.35, 1] }}
/>
</svg>
</div>
<h3 ref={focusThanks} tabIndex={-1} className="mt-1 text-lg font-semibold tracking-[-0.02em] outline-hidden">
Feedback sent
</h3>
<p className="max-w-[30ch] text-sm text-zinc-600 dark:text-zinc-400">{thanksMessage}</p>
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{score} / 5 · {labels[score - 1]}
</p>
<button
type="button"
onClick={edit}
className="mt-1 rounded-full px-3 py-1.5 text-xs font-medium text-zinc-600 underline decoration-zinc-900/20 underline-offset-4 transition-colors outline-hidden hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[color:var(--fs-accent)] dark:text-zinc-400 dark:decoration-white/20 dark:hover:text-white"
>
Edit response
</button>
</motion.div>
) : (
<motion.form key="form" ref={focusChosen} {...fade} onSubmit={submit} className="flex flex-col gap-5">
<div className="flex items-center gap-4">
{showPreview && <MorphFace mood={mood} idle={shown == null} className={`${dims.hero} shrink-0`} />}
<div className="min-w-0">
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">Feedback</span>
<p id={questionId} className="text-[17px] leading-snug font-semibold tracking-[-0.02em]">
{question}
</p>
<p aria-hidden="true" className="h-5 font-mono text-[11px] uppercase tracking-[0.1em] text-zinc-500 dark:text-zinc-400">
{shown != null ? labels[shown - 1] : "Choose a face"}
</p>
</div>
</div>
<div
role="radiogroup"
aria-labelledby={questionId}
onPointerLeave={() => setHover(null)}
className="grid grid-cols-5 gap-1.5"
>
{labels.map((text, i) => {
const step = i + 1;
const checked = score === step;
const lit = checked || hover === step;
return (
<label
key={step}
onPointerEnter={(e) => e.pointerType === "mouse" && setHover(step)}
className="group/step relative flex cursor-pointer flex-col items-center gap-1.5 rounded-xl px-0.5 py-2 transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-1 has-[:focus-visible]:outline-solid has-[:focus-visible]:outline-[color:var(--fs-accent)] hover:bg-zinc-900/[0.03] dark:hover:bg-white/[0.04]"
style={{ ["--fs-step" as string]: STEP_COLORS[i] }}
>
<input
type="radio"
name={groupName}
value={step}
checked={checked}
onChange={() => choose(step)}
aria-label={`${text}, ${step} of 5`}
className="peer absolute inset-0 m-0 cursor-pointer appearance-none rounded-xl opacity-0"
/>
<span
className={`block transition-transform duration-200 ease-out motion-reduce:transition-none ${
checked ? "scale-110" : lit ? "scale-105" : "scale-100"
} ${
lit
? "[--fs-face:var(--fs-step)] [--fs-ink:#18181b] [--fs-line:transparent]"
: "[--fs-cheek:transparent] [--fs-face:transparent] [--fs-ink:#71717a] [--fs-line:rgb(24_24_27/0.18)] dark:[--fs-ink:#a1a1aa] dark:[--fs-line:rgb(255_255_255/0.2)]"
}`}
>
<Face mood={moodOf(step)} className={dims.face} />
</span>
{showLabels && (
<span
aria-hidden="true"
className={`text-center leading-tight ${dims.label} ${
checked ? "font-medium text-zinc-950 dark:text-white" : "text-zinc-500 dark:text-zinc-400"
}`}
>
{text}
</span>
)}
{checked && (
<motion.span
layoutId={reduceMotion ? undefined : `${uid}-dot`}
aria-hidden="true"
className="absolute -bottom-1 h-0.5 w-6 rounded-full bg-[var(--fs-step)]"
transition={{ type: "spring", stiffness: 500, damping: 36 }}
/>
)}
</label>
);
})}
</div>
<AnimatePresence initial={false}>
{score != null && (
<motion.div
key="follow-up"
initial={reduceMotion ? false : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={reduceMotion ? { opacity: 0, transition: { duration: 0 } } : { height: 0, opacity: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 32 }}
className="overflow-hidden"
>
<div className="flex flex-col gap-3 p-0.5">
{showComment && (
<div className="flex flex-col gap-1.5">
<label htmlFor={commentId} className="text-[13px] font-medium text-zinc-700 dark:text-zinc-300">
{commentLabel} <span className="font-normal text-zinc-400 dark:text-zinc-500">(optional)</span>
</label>
<textarea
id={commentId}
name={`${groupName}-comment`}
rows={3}
value={comment}
onChange={(e) => setComment(e.target.value)}
className="w-full resize-none rounded-lg border border-zinc-900/15 bg-transparent px-3 py-2 text-sm text-zinc-950 placeholder:text-zinc-400 outline-hidden transition-colors focus-visible:border-transparent focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[color:var(--fs-accent)] dark:border-white/15 dark:text-zinc-50"
placeholder="Optional details…"
/>
</div>
)}
<div className="flex items-center justify-between gap-3">
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{score} / 5
</span>
<button
type="submit"
className="h-9 rounded-full bg-[var(--fs-accent)] px-4 text-sm font-medium text-zinc-950 transition-[filter,transform] outline-hidden hover:brightness-105 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[color:var(--fs-accent)] active:scale-[0.98]"
>
{submitLabel}
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.form>
)}
</AnimatePresence>
</div>
);
}