Feedback
Pick a mood and the row grows a text box; send and it folds into a thank-you. Three steps, one card that springs between sizes while the content swaps through a blur.
Code
1. Install
npm i framer-motion lucide-react
2. Files
"use client";
// Feedback: pick a mood and the row grows a text box; send and it folds into a thank-you. Three steps, one card that
// springs between sizes while the content swaps through a blur.
import { useEffect, useRef, useState, type CSSProperties } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Check } from "lucide-react";
export type FeedbackCue = "pip" | "back" | "send";
export type FeedbackMood = {
/** The emoji on the button. */
emoji: string;
/** Accessible name of the button. */
label: string;
};
export type FeedbackProps = {
/** The mood buttons, best first. */
moods?: FeedbackMood[];
/** The question on the first step. */
question?: string;
/** Heading of the text step. */
prompt?: string;
/** Placeholder of the text box. */
placeholder?: string;
/** Label of the send button. */
sendLabel?: string;
/** Label of the back button. */
backLabel?: string;
/** The thank-you after sending. */
thanks?: string;
/** Called with the picked mood's index and the typed text when sent. */
onSend?: (mood: number, text: string) => void;
/** Sound hook: "pip" on a mood (pitched by position), "back" on back, "send" on send. */
onSound?: (cue: FeedbackCue, opts?: { at?: number }) => void;
className?: string;
style?: CSSProperties;
};
const MOODS: FeedbackMood[] = [
{ emoji: "😍", label: "Loved it" },
{ emoji: "🙂", label: "Good" },
{ emoji: "😐", label: "Meh" },
{ emoji: "😕", label: "Not really" },
];
const MORPH = { type: "spring", stiffness: 380, damping: 32, mass: 0.9 } as const;
// content fades in after the card has started to spring, and leaves faster than it arrives
const swap = { initial: { opacity: 0, scale: 0.94, filter: "blur(4px)" }, animate: { opacity: 1, scale: 1, filter: "blur(0px)", transitionEnd: { filter: "none" } }, exit: { opacity: 0, scale: 0.94, filter: "blur(4px)", transition: { duration: 0.14, ease: [0.4, 0, 1, 1] as const } }, transition: { duration: 0.2, delay: 0.08, ease: [0.22, 1, 0.36, 1] as const } };
export function Feedback({ moods = MOODS, question = "Was this helpful?", prompt = "Tell us more", placeholder = "What could be better?", sendLabel = "Send", backLabel = "Back", thanks = "Thanks, noted.", onSend, onSound, className, style }: FeedbackProps) {
const [mood, setMood] = useState<number | null>(null);
const [text, setText] = useState("");
const [sent, setSent] = useState(false);
const reset = useRef(0);
const root = useRef<HTMLDivElement>(null);
const land = useRef<number | null>(null);
useEffect(() => () => clearTimeout(reset.current), []);
const pickMood = (index: number) => {
onSound?.("pip", { at: 1 - index / Math.max(1, moods.length - 1) });
setMood(index);
};
const goBack = () => {
land.current = mood ?? 0;
onSound?.("back");
setMood(null);
};
const send = (choice: FeedbackMood) => {
onSound?.("send");
onSend?.(moods.indexOf(choice), text);
setSent(true);
reset.current = window.setTimeout(() => {
if (root.current?.contains(document.activeElement)) land.current = 0;
setSent(false);
setMood(null);
setText("");
}, 1800);
};
const picked = mood === null ? undefined : moods[mood];
const step = sent ? 2 : picked ? 1 : 0;
// buttons that leave hand focus on: thanks line after sending, then a mood button after Back or the reset
useEffect(() => {
const el = root.current;
if (step === 2) el?.querySelector<HTMLElement>("[tabindex='-1']")?.focus();
else if (step === 0 && land.current !== null) {
el?.querySelectorAll<HTMLElement>(".tfb-mood")[land.current]?.focus();
land.current = null;
}
}, [step]);
const size = step === 0 ? { width: 267, height: 56 } : step === 1 ? { width: 280, height: 150 } : { width: 200, height: 56 };
return (
<div ref={root} className={["tfb-root", className].filter(Boolean).join(" ")} style={style}>
<motion.div className="tfb-card" initial={false} animate={size} transition={MORPH}>
<AnimatePresence mode="popLayout" initial={false}>
{step === 0 && (
<motion.div key="moods" className="tfb-row" {...swap}>
<span className="tfb-say">{question}</span>
{moods.map((m, i) => (
<button key={m.label} type="button" className="tfb-mood" onClick={() => pickMood(i)} aria-label={m.label}>
{m.emoji}
</button>
))}
</motion.div>
)}
{step === 1 && picked && (
<motion.div key="text" className="tfb-form" {...swap}>
<div className="tfb-head">
<span className="tfb-picked">{picked.emoji}</span>
<span className="tfb-say">{prompt}</span>
<span className="tfb-grow" />
<button type="button" className="tfb-back" onClick={goBack}>
{backLabel}
</button>
</div>
<textarea autoFocus rows={2} placeholder={placeholder} aria-label={prompt} value={text} onChange={(e) => setText(e.target.value)} />
<button
type="button"
className="tfb-send"
onClick={() => send(picked)}
>
{sendLabel}
</button>
</motion.div>
)}
{step === 2 && (
<motion.div key="sent" className="tfb-row" tabIndex={-1} style={{ justifyContent: "center" }} {...swap}>
<span className="tfb-tick">
<Check size={13} strokeWidth={2.5} />
</span>
<span className="tfb-say">{thanks}</span>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
);
}
.tfb-root,
.tfb-root * {
box-sizing: border-box;
}
:where(.tfb-root) button {
font: inherit;
color: inherit;
background: none;
border: 0;
padding: 0;
margin: 0;
}
:where(.tfb-root) :focus-visible {
outline: 2px solid var(--ts-focus, #3446eb);
outline-offset: 2px;
}
.tfb-row[tabindex]:focus-visible {
outline: 0;
}
.tfb-root {
font-family: var(--ts-font, system-ui, sans-serif);
display: grid;
place-items: center;
width: 300px;
height: 170px;
}
.tfb-card {
position: relative;
overflow: hidden;
border-radius: var(--ts-r-pop);
background: var(--ts-raised);
color: var(--ts-fg);
box-shadow: var(--ts-edge);
}
.tfb-row,
.tfb-form {
position: absolute;
inset: 0;
}
.tfb-row {
display: flex;
align-items: center;
gap: 2px;
padding: 0 10px 0 14px;
}
.tfb-say {
white-space: nowrap;
font-size: 13.5px;
font-weight: 500;
letter-spacing: -0.01em;
margin-right: 6px;
}
.tfb-mood {
flex: none;
width: 30px;
height: 30px;
border: 0;
border-radius: 999px;
background: transparent;
font-size: 17px;
cursor: pointer;
transition: scale 0.22s cubic-bezier(.22, 1, .36, 1), background 0.16s cubic-bezier(.22, 1, .36, 1);
}
.tfb-mood:hover {
scale: 1.04;
background: rgba(var(--ts-fg-rgb), 0.06);
}
.tfb-mood:active {
scale: 0.9;
}
.tfb-form {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 12px 12px 14px;
}
.tfb-head {
display: flex;
align-items: center;
gap: 8px;
}
.tfb-picked {
font-size: 18px;
}
.tfb-grow {
flex: 1;
}
.tfb-back {
border: 0;
background: transparent;
color: var(--ts-fg-muted);
font: inherit;
font-size: 12.5px;
cursor: pointer;
transition: color 0.16s cubic-bezier(.22, 1, .36, 1);
}
.tfb-back:hover {
color: var(--ts-fg);
}
.tfb-form textarea {
flex: 1;
padding: 8px 10px;
border: 0;
border-radius: var(--ts-r-inner);
background: rgba(var(--ts-fg-rgb), 0.06);
color: inherit;
font: inherit;
font-size: 13px;
line-height: 1.4;
outline: 0;
resize: none;
transition: box-shadow 0.16s cubic-bezier(.22, 1, .36, 1);
}
.tfb-form textarea:focus {
box-shadow: inset 0 0 0 1.5px var(--ts-fg);
}
.tfb-send {
align-self: flex-end;
height: 32px;
padding: 0 14px;
border: 0;
border-radius: 999px;
background: var(--ts-fg);
color: var(--ts-raised);
font: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: scale 0.22s cubic-bezier(.22, 1, .36, 1);
}
.tfb-send:active {
scale: 0.94;
}
.tfb-tick {
width: 22px;
height: 22px;
border-radius: 11px;
background: var(--ts-success);
color: var(--ts-fg-inverse);
display: grid;
place-items: center;
margin-right: 8px;
}
/*
* Teseria tokens — the only colours, fonts and corner radii a block reads.
*
* Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
* the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
* any ancestor, or the OS setting unless [data-theme="light"] is set.
*
* Surfaces page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
* Text fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
* Lines border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
* dark; composes with other shadows)
* Signals accent · focus · success · warning · danger
* Corners controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
* (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
*/
:root {
--ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ts-r-panel: 24px;
--ts-r-pop: 18px;
--ts-r-inner: 12px;
--ts-page: #ffffff;
--ts-raised: #ffffff;
--ts-sunken: #f3f2f0;
--ts-tint-1: #f5f4f2;
--ts-tint-2: #ecebe8;
--ts-tint-3: #e3e1dd;
--ts-fg: #161514;
--ts-fg-rgb: 22, 21, 20;
--ts-fg-secondary: #3a3836;
--ts-fg-tertiary: #585552;
--ts-fg-muted: #6d6a66;
--ts-fg-faint: #89857f;
--ts-fg-inverse: #ffffff;
--ts-border: rgba(22, 21, 20, 0.1);
--ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);
--ts-accent: #3446eb;
--ts-focus: #3446eb;
--ts-success: #13804a;
--ts-warning: #a9650b;
--ts-danger: #df474e;
}
:root[data-theme="dark"] {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
}
3. Usage
import { Feedback } from "./Feedback";
import "./Feedback.css";
export default function Demo() {
return <Feedback />;
}