Review Submit
Star fills bloom from the middle, then the form folds away and the posted review rises into its place.
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 · Review Submit
*
* Rating fills bloom from the middle of each star; submitting folds the
* form away upward and the posted review rises into the space it left.
* The two states share one fixed frame, so nothing around the widget
* reflows and no glyph is ever scaled by a container resizing.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the card reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `productName`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ReviewStarsSubmitProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Item being reviewed. */
productName?: string;
/** Rating selected before the reader touches anything. */
defaultRating?: number;
/** Fires with the posted rating and comment. */
onSubmit?: (rating: number, comment: string) => void;
};
type VariantConfig = {
/** Spring for a star fill blooming in. */
star: { type: "spring"; stiffness: number; damping: number };
/** Spring for the swap between form and posted card. */
swap: { type: "spring"; stiffness: number; damping: number };
/** How far each state travels through the swap, in pixels. */
travel: number;
/** Seconds between form rows leaving. */
stagger: number;
};
// Quality rule: the star fill is the only thing here allowed to scale,
// because it is a glyph drawn as vector art rather than type. Every
// spring is at or above a 0.8 damping ratio — a rating control that
// wobbles undermines the seriousness of the thing being rated.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Quick and flat. For a review prompt embedded in a long order page.
subtle: {
star: { type: "spring", stiffness: 620, damping: 44 },
swap: { type: "spring", stiffness: 520, damping: 42 },
travel: 10,
stagger: 0.02,
},
// A single soft settle on the fill and on the swap. All-purpose.
default: {
star: { type: "spring", stiffness: 460, damping: 36 },
swap: { type: "spring", stiffness: 400, damping: 34 },
travel: 18,
stagger: 0.035,
},
// More travel through the fold, for a dedicated review screen.
playful: {
star: { type: "spring", stiffness: 420, damping: 33 },
swap: { type: "spring", stiffness: 340, damping: 31 },
travel: 26,
stagger: 0.05,
},
};
const STAR_GOLD = "#E0A32E";
const ACCENT = "#7C7CF0";
/** 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 that is
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const STAR_PATH =
"M10 1.6 12.6 7 18.5 7.9 14.2 12 15.3 17.9 10 15.1 4.7 17.9 5.8 12 1.5 7.9 7.4 7z";
const SCORE_WORDS = ["", "Poor", "Fair", "Good", "Great", "Excellent"];
function Stars({
rating,
onRate,
size,
spring,
reduceMotion,
}: {
rating: number;
onRate?: (value: number) => void;
size: number;
spring: VariantConfig["star"];
reduceMotion: boolean | null;
}) {
const interactive = typeof onRate === "function";
return (
<div style={{ display: "flex", gap: 4 }} role={interactive ? "radiogroup" : undefined}>
{[1, 2, 3, 4, 5].map((value) => {
const filled = value <= rating;
const glyph = (
<span
style={{
position: "relative",
display: "block",
width: size,
height: size,
}}
>
<svg
width={size}
height={size}
viewBox="0 0 20 20"
fill="none"
stroke={tone(30)}
strokeWidth="1.4"
strokeLinejoin="round"
aria-hidden
style={{ display: "block" }}
>
<path d={STAR_PATH} />
</svg>
{/* The fill blooms out of the middle of the outline rather
than swapping colour — the outline never moves, so the
row keeps its rhythm while the score changes. */}
<motion.svg
width={size}
height={size}
viewBox="0 0 20 20"
fill={STAR_GOLD}
aria-hidden
initial={false}
animate={{
scale: filled ? 1 : 0.35,
opacity: filled ? 1 : 0,
}}
transition={
reduceMotion ? { duration: 0.14, ease: "easeOut" } : spring
}
style={{ position: "absolute", inset: 0, display: "block" }}
>
<path d={STAR_PATH} />
</motion.svg>
</span>
);
if (!interactive) return <span key={value}>{glyph}</span>;
return (
<button
key={value}
type="button"
role="radio"
aria-checked={value === rating}
aria-label={`${value} star${value === 1 ? "" : "s"}`}
onClick={() => onRate?.(value)}
style={{
display: "block",
padding: 0,
border: "none",
background: "transparent",
color: "inherit",
cursor: "pointer",
lineHeight: 0,
}}
>
{glyph}
</button>
);
})}
</div>
);
}
export default function ReviewStarsSubmit({
variant = "default",
productName = "Cold brew carafe, 1L",
defaultRating = 0,
onSubmit,
}: ReviewStarsSubmitProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [rating, setRating] = useState(defaultRating);
const [comment, setComment] = useState(
"Lid seals properly and the glass has survived a month of daily use."
);
const [posted, setPosted] = useState(false);
// Reduced motion: the fold becomes a crossfade. Same two states, same
// order, none of the travel.
const enter = reduceMotion
? { initial: { opacity: 0 }, animate: { opacity: 1 } }
: {
initial: { opacity: 0, y: cfg.travel },
animate: { opacity: 1, y: 0 },
};
const swapTransition = reduceMotion
? { duration: 0.18, ease: "easeOut" as const }
: cfg.swap;
return (
<div
style={{
width: 342,
height: 226,
position: "relative",
borderRadius: 18,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
overflow: "hidden",
fontFamily: "inherit",
}}
>
<AnimatePresence initial={false}>
{!posted && (
<motion.form
key="form"
onSubmit={(event) => {
event.preventDefault();
if (rating === 0) return;
setPosted(true);
onSubmit?.(rating, comment);
}}
initial={false}
exit={
reduceMotion
? { opacity: 0, transition: { duration: 0.14 } }
: {
opacity: 0,
y: -cfg.travel,
transition: { duration: 0.2, ease: "easeIn" },
}
}
style={{
position: "absolute",
inset: 0,
padding: "15px 18px 16px",
display: "flex",
flexDirection: "column",
gap: 11,
}}
>
{[
{
key: "head",
node: (
<>
<div style={{ fontSize: 13.5, fontWeight: 650 }}>
Rate your purchase
</div>
<div
style={{ fontSize: 11.5, opacity: 0.55, marginTop: 2 }}
>
{productName}
</div>
</>
),
},
{
key: "stars",
node: (
<div
style={{ display: "flex", alignItems: "center", gap: 10 }}
>
<Stars
rating={rating}
onRate={setRating}
size={24}
spring={cfg.star}
reduceMotion={reduceMotion}
/>
{/* The word changes by crossfade inside a fixed slot:
the stars must not shuffle sideways as the label
grows, and the label itself never scales. */}
<span
style={{
position: "relative",
display: "block",
minWidth: 68,
height: 16,
}}
>
<AnimatePresence initial={false}>
<motion.span
key={SCORE_WORDS[rating] || "none"}
initial={{ opacity: 0, y: reduceMotion ? 0 : 5 }}
animate={{ opacity: rating ? 1 : 0.45, y: 0 }}
exit={{ opacity: 0, y: reduceMotion ? 0 : -5 }}
transition={{ duration: 0.16, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
fontSize: 12,
fontWeight: 600,
lineHeight: "16px",
whiteSpace: "nowrap",
color: rating ? STAR_GOLD : undefined,
}}
>
{SCORE_WORDS[rating] || "Choose a rating"}
</motion.span>
</AnimatePresence>
</span>
</div>
),
},
{
key: "comment",
node: (
<textarea
value={comment}
onChange={(event) => setComment(event.target.value)}
rows={2}
aria-label="Your review"
style={{
width: "100%",
display: "block",
resize: "none",
padding: "9px 11px",
fontSize: 12,
lineHeight: 1.5,
fontFamily: "inherit",
borderRadius: 11,
border: `1px solid ${tone(13)}`,
background: tone(5),
color: "inherit",
boxSizing: "border-box",
}}
/>
),
},
{
key: "submit",
node: (
<button
type="submit"
disabled={rating === 0}
style={{
width: "100%",
padding: "9px 14px",
fontSize: 12.5,
fontWeight: 650,
fontFamily: "inherit",
borderRadius: 10,
border: "none",
background: rating === 0 ? tone(12) : ACCENT,
color: rating === 0 ? "inherit" : "#FFFFFF",
opacity: rating === 0 ? 0.6 : 1,
cursor: rating === 0 ? "not-allowed" : "pointer",
}}
>
Post review
</button>
),
},
].map((row, index) => (
// Rows leave in the order they were read, a beat apart, so
// the form folds away rather than blinking out.
<motion.div
key={row.key}
initial={false}
exit={
reduceMotion
? { opacity: 0, transition: { duration: 0.12 } }
: {
opacity: 0,
y: -8,
transition: {
duration: 0.18,
ease: "easeIn",
delay: index * cfg.stagger,
},
}
}
style={row.key === "submit" ? { marginTop: "auto" } : undefined}
>
{row.node}
</motion.div>
))}
</motion.form>
)}
{posted && (
<motion.div
key="posted"
{...enter}
transition={{
...swapTransition,
delay: reduceMotion ? 0.1 : 0.12,
opacity: {
duration: 0.22,
ease: "easeOut",
delay: reduceMotion ? 0.1 : 0.12,
},
}}
style={{
position: "absolute",
inset: 0,
padding: "15px 18px 16px",
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span
aria-hidden
style={{
display: "grid",
placeItems: "center",
width: 30,
height: 30,
borderRadius: 999,
fontSize: 11.5,
fontWeight: 700,
color: "#FFFFFF",
background: `linear-gradient(140deg, ${ACCENT}, #4F8BF0)`,
}}
>
AR
</span>
<span style={{ flex: 1, minWidth: 0 }}>
<span
style={{ display: "block", fontSize: 12.5, fontWeight: 650 }}
>
Your review
</span>
<span style={{ display: "block", fontSize: 11, opacity: 0.5 }}>
Posted just now · verified purchase
</span>
</span>
<Stars
rating={rating}
size={14}
spring={cfg.star}
reduceMotion={reduceMotion}
/>
</div>
<p
style={{
margin: 0,
padding: "10px 12px",
fontSize: 12,
lineHeight: 1.55,
borderRadius: 12,
background: tone(5),
border: `1px solid ${tone(11)}`,
opacity: 0.8,
}}
>
{comment}
</p>
<button
type="button"
onClick={() => setPosted(false)}
style={{
marginTop: "auto",
alignSelf: "flex-start",
padding: "7px 12px",
fontSize: 12,
fontWeight: 600,
fontFamily: "inherit",
borderRadius: 9,
border: `1px solid ${tone(14)}`,
background: tone(7),
color: "inherit",
cursor: "pointer",
}}
>
Edit review
</button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}About this pattern
Rating and posting treated as one moment. Each star keeps a static outline while a filled copy blooms out of its centre, so the row holds its rhythm as the score changes and the score word crossfades in a fixed slot rather than shoving the stars sideways. On submit the form's rows leave upward in the order they were read and the posted card rises into the space. Both states share one fixed frame, so nothing around the widget reflows and no glyph is scaled by a container resizing.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Product page
Stay ratings fill from the centre of each star as you drag across the row.
Related patterns
- Receipt UnrollThe payment clears and the receipt unrolls beneath it, line items settling in behind the torn edge.
- Rating Stars FillStars light left to right under the pointer, each settling a fraction after the one before it.
- Add to Cart FlyThe product tile arcs from the card into the cart glyph, and the badge ticks over on arrival.
