Answer Rating Flip
The chosen thumb turns onto its filled face and the row hands over to a short thank-you.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Answer Rating Flip
*
* Rating an answer: the chosen thumb turns over onto its filled face and
* settles, the option not taken steps aside, and the row hands itself
* over to a short acknowledgement.
*
* Self-contained: depends only on `react` and `motion`. Surfaces are
* mixed from the inherited text color, so it reads correctly on a light
* page and on a dark one.
* Works with zero props; tune via `variant`, `accent`, `question`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type AnswerRatingFlipProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Prompt shown before a rating is given. */
question?: string;
/** Shown once a rating has been given. */
acknowledgement?: string;
/** Fill for the chosen thumb. */
accent?: string;
/** Fires with the rating the reader gave. */
onRate?: (rating: "up" | "down") => void;
};
type VariantConfig = {
/** ms the flip is allowed before the row hands over. */
handoverMs: number;
/** px the acknowledgement travels in from. */
travel: number;
flipSpring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: the thumb is an icon, so it may turn; the copy is text,
// so it only fades and slides. The flip spring sits above a 0.8 damping
// ratio — a thumb that rocks past its filled face twice turns a one-bit
// answer into a performance.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A quick half-turn and an immediate handover. For a rating row that
// appears under every message in a long thread.
subtle: {
handoverMs: 220,
travel: 4,
flipSpring: { type: "spring", stiffness: 560, damping: 44 },
},
// The turn is legible and the acknowledgement follows it. ζ ≈ 0.89 —
// the all-purpose setting.
default: {
handoverMs: 320,
travel: 7,
flipSpring: { type: "spring", stiffness: 500, damping: 40 },
},
// A slower turn with one soft settle, for a single answer the product
// genuinely wants feedback on.
playful: {
handoverMs: 430,
travel: 11,
flipSpring: { type: "spring", stiffness: 380, damping: 32 },
},
};
/** Theme-adaptive neutral: `currentColor` is the text color this
* component inherits — near-black on a light page, near-white on a dark
* one — so mixing it with `transparent` yields a surface, border or fill
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const THUMB_PATH =
"M5.6 14.1V6.9l3-4.5c.25-.38.72-.55 1.15-.4.62.2.98.85.85 1.5l-.42 2.2h3.06c.92 0 1.6.83 1.43 1.73l-.85 4.4c-.13.7-.75 1.2-1.46 1.2H5.6Z";
const THUMB_CUFF = "M5.6 6.9H3.4c-.6 0-1.1.5-1.1 1.1v4.9c0 .6.5 1.1 1.1 1.1h2.2";
function Thumb({ down, filled }: { down?: boolean; filled?: boolean }) {
return (
<svg
width="15"
height="15"
viewBox="0 0 17 16"
fill="none"
aria-hidden
style={{ transform: down ? "rotate(180deg)" : undefined, display: "block" }}
>
<path
d={THUMB_PATH}
fill={filled ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth="1.3"
strokeLinejoin="round"
/>
<path
d={THUMB_CUFF}
fill={filled ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth="1.3"
strokeLinejoin="round"
/>
</svg>
);
}
export default function AnswerRatingFlip({
variant = "default",
question = "Was this helpful?",
acknowledgement = "Thanks — that helps.",
accent = "#5B5BD6",
onRate,
}: AnswerRatingFlipProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [rating, setRating] = useState<"up" | "down" | null>(null);
const [acknowledged, setAcknowledged] = useState(false);
const [ring, setRing] = useState<string | null>(null);
const rate = (value: "up" | "down") => {
if (rating) return;
setRating(value);
onRate?.(value);
// The turn is allowed to finish before the row changes what it says,
// so the two events read as cause and effect rather than as one blur.
window.setTimeout(
() => setAcknowledged(true),
reduceMotion ? 0 : cfg.handoverMs
);
};
const button = (value: "up" | "down", label: string) => {
const chosen = rating === value;
const dismissed = rating !== null && !chosen;
return (
<motion.button
type="button"
onClick={() => rate(value)}
aria-label={label}
aria-pressed={chosen}
disabled={rating !== null}
onFocus={(event) => setRing(event.currentTarget.matches(":focus-visible") ? value : null)}
onBlur={() => setRing(null)}
initial={false}
// The option not taken steps aside rather than vanishing: it
// fades and drifts a few pixels toward the chosen one.
animate={{
opacity: dismissed ? 0 : 1,
x: dismissed && !reduceMotion ? (value === "up" ? 6 : -6) : 0,
}}
transition={{ duration: reduceMotion ? 0.12 : 0.18, ease: "easeOut" }}
style={{
display: "grid",
placeItems: "center",
width: 28,
height: 28,
padding: 0,
fontFamily: "inherit",
color: chosen ? accent : "inherit",
background: chosen ? tone(8) : "transparent",
border: `1px solid ${chosen ? tone(14) : tone(11)}`,
borderRadius: 8,
cursor: rating ? "default" : "pointer",
boxShadow: ring === value ? `0 0 0 3px ${tone(20)}` : "none",
outline: "none",
perspective: 320,
}}
>
<motion.span
initial={false}
// Reduced motion: no turn. The two faces crossfade, which still
// reports the state change without rotating anything.
animate={{ rotateY: chosen && !reduceMotion ? 180 : 0 }}
transition={reduceMotion ? { duration: 0 } : cfg.flipSpring}
style={{
position: "relative",
display: "block",
width: 15,
height: 15,
transformStyle: "preserve-3d",
}}
>
<motion.span
initial={false}
animate={{ opacity: reduceMotion && chosen ? 0 : 1 }}
transition={{ duration: 0.14 }}
style={{
position: "absolute",
inset: 0,
backfaceVisibility: "hidden",
opacity: 0.72,
}}
>
<Thumb down={value === "down"} />
</motion.span>
<span
style={{
position: "absolute",
inset: 0,
backfaceVisibility: "hidden",
// Under reduced motion nothing turns, so the filled face is
// brought forward by opacity instead of by rotation.
transform: reduceMotion ? undefined : "rotateY(180deg)",
opacity: reduceMotion ? (chosen ? 1 : 0) : 1,
}}
>
<Thumb down={value === "down"} filled />
</span>
</motion.span>
</motion.button>
);
};
return (
<div
style={{
width: 300,
display: "flex",
alignItems: "center",
gap: 10,
minHeight: 30,
color: "inherit",
}}
>
<AnimatePresence mode="wait" initial={false}>
{acknowledged ? (
<motion.span
key="ack"
role="status"
initial={
reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.travel }
}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, ease: "easeOut" }}
style={{
display: "inline-flex",
alignItems: "center",
gap: 7,
fontSize: 12.5,
opacity: 0.7,
}}
>
<span style={{ color: accent, display: "inline-flex" }}>
<Thumb down={rating === "down"} filled />
</span>
{acknowledgement}
</motion.span>
) : (
<motion.span
key="ask"
initial={false}
exit={{
opacity: 0,
y: reduceMotion ? 0 : -cfg.travel,
transition: { duration: 0.14, ease: "easeIn" },
}}
style={{ display: "inline-flex", alignItems: "center", gap: 9 }}
>
<span style={{ fontSize: 12.5, opacity: 0.55 }}>{question}</span>
<span style={{ display: "inline-flex", gap: 6 }}>
{button("up", "This answer was helpful")}
{button("down", "This answer was not helpful")}
</span>
</motion.span>
)}
</AnimatePresence>
</div>
);
}About this pattern
Feedback on a generated answer, given in one click and acknowledged without ceremony. The thumb turns a half-rotation onto its filled face on a well-damped spring, the option not taken fades and drifts aside, and only once the turn has landed does the row swap its prompt for a brief acknowledgement — so the two events read as cause and effect instead of one blur. The thumb is an icon and may rotate; the copy is text and only fades and slides. Under reduced motion the faces crossfade and nothing turns.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- AI assistant
A thumbs row under each reply that fills the chosen icon on click.
Related patterns
- Context Pill AttachA dragged file is caught by the prompt bar and lands inside it as a context pill.
- Prompt Submit LiftThe composed prompt lifts out of the field and docks as a sent message while the thread makes room.
- Prompt Template FillPicking a starter writes its outline into the composer with the blanks lighting up.