Social proof
Review Card
The stars light up one by one, the rating and the review count climb, and the reviews take turns.
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(useGSAP);
export type Review = {
/** Who wrote it. */
name: string;
/** The review text. */
text: string;
/** When it was written, e.g. "2 days ago". */
when?: string;
};
export type ReviewCardProps = {
/** Average rating, from 0 to 5. */
rating?: number;
/** Total number of reviews. */
count?: number;
/** Reviews shown one after another. */
reviews?: Review[];
/** Heading above the rating. */
title?: string;
/** Word after the review count. */
countLabel?: string;
/** Locale used to format the numbers. */
locale?: string;
/** Color of the stars. */
starColor?: string;
/** Replay forever with a pause between runs. */
loop?: boolean;
className?: string;
};
const STAR = "M12 2.6l2.9 5.9 6.5.9-4.7 4.6 1.1 6.5L12 17.4l-5.8 3.1 1.1-6.5L2.6 9.4l6.5-.9z";
const DEFAULT_REVIEWS: Review[] = [
{ name: "Marina S.", when: "2 days ago", text: "Booked on a Sunday night and got a reply in minutes. The team was on time and left everything spotless." },
{ name: "Rafael T.", when: "1 week ago", text: "Clear prices, no surprises at the end. I have already sent two friends here." },
{ name: "Julia P.", when: "3 weeks ago", text: "Friendly from the first message to the last. Best service I have had in years." },
];
export default function ReviewCard({
rating = 4.9,
count = 312,
reviews = DEFAULT_REVIEWS,
title = "Customer reviews",
countLabel = "reviews",
locale = "en-US",
starColor = "#f5b400",
loop = true,
className = "",
}: ReviewCardProps) {
const root = useRef<HTMLDivElement>(null);
const ratingText = useRef<HTMLSpanElement>(null);
const countText = useRef<HTMLSpanElement>(null);
const score = new Intl.NumberFormat(locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 });
const whole = new Intl.NumberFormat(locale);
useGSAP(
() => {
const q = gsap.utils.selector(root);
const num = { r: 0, c: 0 };
const write = () => {
if (ratingText.current) ratingText.current.textContent = score.format(num.r);
if (countText.current) countText.current.textContent = whole.format(Math.round(num.c));
};
const blocks = q("[data-review]");
const hold = 3.4;
const tl = gsap.timeline({ repeat: loop ? -1 : 0, repeatDelay: 1.4 });
tl.set(q("[data-card]"), { y: 14, autoAlpha: 0 })
.set(q("[data-star]"), { scale: 0 })
.set(q("[data-star-fill]"), { scaleX: 0 })
.set(blocks, { autoAlpha: 0, y: 0 })
.set(q("[data-word]"), { autoAlpha: 0, y: 8, filter: "blur(4px)" })
.set(num, { r: 0, c: 0 })
.add(write, 0)
.to(q("[data-card]"), { y: 0, autoAlpha: 1, duration: 0.5, ease: "power3.out" }, 0.2)
.to(q("[data-star]"), { scale: 1, duration: 0.35, ease: "back.out(2.6)", stagger: 0.09 }, 0.5)
.to(q("[data-star-fill]"), { scaleX: 1, duration: 0.3, ease: "power2.out", stagger: 0.09 }, 0.62)
.to(num, { r: rating, c: count, duration: 1.2, ease: "power2.out", onUpdate: write }, 0.5);
blocks.forEach((block, i) => {
const at = 1.5 + i * hold;
tl.set(block, { autoAlpha: 1 }, at).to(
block.querySelectorAll("[data-word]"),
{ autoAlpha: 1, y: 0, filter: "blur(0px)", duration: 0.35, ease: "power2.out", stagger: 0.035 },
at,
);
// A última fica na tela: é o quadro final (e o que aparece com movimento reduzido).
if (i < blocks.length - 1) tl.to(block, { autoAlpha: 0, y: -6, duration: 0.3, ease: "power2.in" }, at + hold - 0.35);
});
tl.to({}, { duration: 1.6 });
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) tl.progress(1).pause();
},
{ scope: root, dependencies: [rating, count, locale, loop, reviews.map((r) => r.text).join("|")] },
);
return (
<div ref={root} className={`w-[380px] max-w-full ${className}`}>
<div data-card className="rounded-2xl bg-white p-5 text-neutral-900 shadow-xl">
<p className="text-[11px] font-semibold tracking-[0.12em] text-neutral-500 uppercase">{title}</p>
<div className="mt-2 flex items-center gap-3">
<span ref={ratingText} className="text-[38px] leading-none font-bold tabular-nums tracking-tight">
{score.format(0)}
</span>
<div>
<div className="flex gap-0.5" aria-label={`${score.format(rating)} / 5`}>
{[0, 1, 2, 3, 4].map((i) => {
const fill = Math.min(Math.max(rating - i, 0), 1);
return (
<span key={i} data-star className="relative block h-[18px] w-[18px]">
<svg viewBox="0 0 24 24" className="absolute inset-0 h-full w-full" aria-hidden="true">
<path d={STAR} fill="#e5e5e5" />
</svg>
<span className="absolute inset-y-0 left-0 overflow-hidden" style={{ width: `${fill * 100}%` }}>
<span data-star-fill className="block h-full w-[18px] origin-left">
<svg viewBox="0 0 24 24" className="h-[18px] w-[18px]" aria-hidden="true">
<path d={STAR} fill={starColor} />
</svg>
</span>
</span>
</span>
);
})}
</div>
<p className="mt-1 text-[12.5px] text-neutral-500">
<span ref={countText} className="tabular-nums">
{whole.format(0)}
</span>{" "}
{countLabel}
</p>
</div>
</div>
<div className="mt-4 border-t border-neutral-200 pt-4">
{/* Todas no mesmo lugar da grade: a altura é a da maior, nada pula quando uma troca pela outra. */}
<div className="grid">
{reviews.map((review, i) => (
<figure key={i} data-review className="[grid-area:1/1]">
<div className="flex items-center gap-2.5">
<span
aria-hidden="true"
className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-neutral-200 text-[12px] font-semibold text-neutral-600"
>
{review.name.slice(0, 1)}
</span>
<figcaption className="min-w-0">
<div className="text-[13px] font-semibold">{review.name}</div>
{review.when ? <div className="text-[11.5px] text-neutral-500">{review.when}</div> : null}
</figcaption>
</div>
<blockquote className="mt-2.5 text-[14px] leading-[1.5] text-neutral-700">
{review.text.split(" ").map((word, j) => (
<span key={j} data-word className="inline-block whitespace-pre">
{word}{" "}
</span>
))}
</blockquote>
</figure>
))}
</div>
</div>
</div>
</div>
);
}
Paste this into Claude Code inside your project. It installs the skills your agent needs, asks for your texts and colors, and recreates this animation in your stack.
SkillsGSAP skillsHyperFrames · optional, for video
Claude Code
Recreate the "Review Card" animated component from Motionshelf (https://motionshelf-pi.vercel.app) in my current project, adapted to my brand. Answer me in the language I write to you in. Work in this order: 1. Tools: Check whether the official GSAP skills are installed (gsap-core, gsap-timeline, gsap-react). If they are not, ask me and then run `npx skills add https://github.com/greensock/gsap-skills` (choose Claude Code). If they do not show up in this session yet, read the SKILL.md files the installer created for gsap-timeline and gsap-react before writing code. 2. Project: Look at my project: framework (Next.js, Vite, plain HTML…), TypeScript or JavaScript, Tailwind or plain CSS. Build it as one component that fits this stack. In React use gsap and @gsap/react; in plain HTML use GSAP from npm or a CDN. 3. Ask me before coding: Ask me these in a single message, showing the example values as defaults: - the real rating, the real review count and real reviews (name, text, when) - the heading and the language of the numbers - the star color - the language of the texts 4. The animation What it looks like: A white review card: a small heading, a big average rating next to five stars and the review count, a divider, then one review with a round initial avatar, the name, when it was written and the text. Step by step: - At 0.2 s the card rises in (y 14 → 0, 0.5 s, power3.out). - At 0.5 s the stars pop one by one (scale 0 → 1, back.out(2.6), stagger 0.09 s) and their color fills from the left; the last star fills only by the decimal (4.9 → 90%). At the same time the rating counts from 0 to 4.9 and the review count from 0 to 312 in 1.2 s, formatted with Intl.NumberFormat. - From 1.5 s each review takes 3.4 s: its words come in one after another (autoAlpha, y 8 → 0, blur 4px → 0, stagger 0.035 s), then it fades up and out 0.35 s before the next. The last review stays. Rules: - Use one GSAP timeline that loops forever with a short pause between runs (repeat: -1). If the step by step gives the pause, use that value; otherwise repeatDelay about 1.4 s. - Set every starting state with set() at the start of the timeline, never with from(): on the second loop from() starts from the wrong place. - Elements that appear later and would push the layout (new chat bubbles, new rows) stay display:none until their turn; elements that already have their place in the layout (a subtitle, a button) start hidden with autoAlpha 0, so nothing jumps. - If there is a cursor or pointer, it aims at the measured center of its target (getBoundingClientRect), never at fixed pixel coordinates; if the component is scaled, divide by the scale. - Blur only in the middle of a movement and always end at blur(0). - With prefers-reduced-motion, jump to the final frame and stop (tl.progress(1).pause()). - In React, use useGSAP from @gsap/react with a scope ref, so the timeline is cleaned up on unmount. - All example content stays generic (no real brands, logos or people). - Stack all the reviews in the same grid cell so the card keeps the height of the longest one and nothing jumps. - Use only real reviews, with permission. Do not copy the logo of a review platform. 5. Check: Run it and watch the first and the third loop (they must look the same). Check it at 390 px wide with no sideways scroll, with reduced motion turned on, and with a clean console. Tell me what you checked. 6. Video (optional): If I also want this as an MP4 (ads, stories, WhatsApp status), install the HyperFrames plugin with `claude plugin marketplace add heygen-com/hyperframes` and `claude plugin install hyperframes@hyperframes`, then use /hyperframes:hyperframes to render the same animation in 9:16, 1:1 or 16:9. Rendering needs FFmpeg installed, or render with --docker (downloads about 3.3 GB the first time). Ask me before installing. Shortcut: If I only want the ready-made component instead, it installs with `npx shadcn@latest add https://motionshelf-pi.vercel.app/r/review-card.json`.
Other agentsUsing Cursor, Codex, Gemini CLI or another agent? This version tells it to find its own way to load the same skills before it starts.
Other agents
Recreate the "Review Card" animated component from Motionshelf (https://motionshelf-pi.vercel.app) in my current project, adapted to my brand. Answer me in the language I write to you in. Work in this order: 1. Tools: These instructions were written for Claude Code, which uses "skills" (folders with a SKILL.md that teach it a technique). First find out how you load skills, rules or instructions, and get the same knowledge: (a) if your agent is supported by the skills installer, ask me and run `npx skills add https://github.com/greensock/gsap-skills --agent <your-agent>` (for example codex, opencode, antigravity); (b) otherwise copy the skill folders from https://github.com/greensock/gsap-skills into your skills or rules directory (in Cursor: Settings → Rules → Remote Rule (GitHub) → greensock/gsap-skills); (c) if you cannot load skills at all, open the SKILL.md files in https://github.com/greensock/gsap-skills and read them as instructions before coding. Tell me which path you used. 2. Project: Look at my project: framework (Next.js, Vite, plain HTML…), TypeScript or JavaScript, Tailwind or plain CSS. Build it as one component that fits this stack. In React use gsap and @gsap/react; in plain HTML use GSAP from npm or a CDN. 3. Ask me before coding: Ask me these in a single message, showing the example values as defaults: - the real rating, the real review count and real reviews (name, text, when) - the heading and the language of the numbers - the star color - the language of the texts 4. The animation What it looks like: A white review card: a small heading, a big average rating next to five stars and the review count, a divider, then one review with a round initial avatar, the name, when it was written and the text. Step by step: - At 0.2 s the card rises in (y 14 → 0, 0.5 s, power3.out). - At 0.5 s the stars pop one by one (scale 0 → 1, back.out(2.6), stagger 0.09 s) and their color fills from the left; the last star fills only by the decimal (4.9 → 90%). At the same time the rating counts from 0 to 4.9 and the review count from 0 to 312 in 1.2 s, formatted with Intl.NumberFormat. - From 1.5 s each review takes 3.4 s: its words come in one after another (autoAlpha, y 8 → 0, blur 4px → 0, stagger 0.035 s), then it fades up and out 0.35 s before the next. The last review stays. Rules: - Use one GSAP timeline that loops forever with a short pause between runs (repeat: -1). If the step by step gives the pause, use that value; otherwise repeatDelay about 1.4 s. - Set every starting state with set() at the start of the timeline, never with from(): on the second loop from() starts from the wrong place. - Elements that appear later and would push the layout (new chat bubbles, new rows) stay display:none until their turn; elements that already have their place in the layout (a subtitle, a button) start hidden with autoAlpha 0, so nothing jumps. - If there is a cursor or pointer, it aims at the measured center of its target (getBoundingClientRect), never at fixed pixel coordinates; if the component is scaled, divide by the scale. - Blur only in the middle of a movement and always end at blur(0). - With prefers-reduced-motion, jump to the final frame and stop (tl.progress(1).pause()). - In React, use useGSAP from @gsap/react with a scope ref, so the timeline is cleaned up on unmount. - All example content stays generic (no real brands, logos or people). - Stack all the reviews in the same grid cell so the card keeps the height of the longest one and nothing jumps. - Use only real reviews, with permission. Do not copy the logo of a review platform. 5. Check: Run it and watch the first and the third loop (they must look the same). Check it at 390 px wide with no sideways scroll, with reduced motion turned on, and with a clean console. Tell me what you checked. 6. Video (optional): If I also want this as an MP4 (ads, stories, WhatsApp status), install the HyperFrames skills with `npx skills add heygen-com/hyperframes` (choose your agent) and render the same animation in 9:16, 1:1 or 16:9. Rendering needs FFmpeg installed, or render with --docker (downloads about 3.3 GB the first time). Ask me before installing. Shortcut: If I only want the ready-made component instead, it installs with `npx shadcn@latest add https://motionshelf-pi.vercel.app/r/review-card.json`.
Install
npx shadcn@latest add https://motionshelf-pi.vercel.app/r/review-card.jsonCopies the file into your project. Requires React 19, Tailwind CSS v4 and GSAP (installed as a dependency).
Props
| Name | Type | Default | Description |
|---|---|---|---|
| rating | number | 4.9 | Average rating, from 0 to 5. |
| count | number | 312 | Total number of reviews. |
| reviews | Review[] | 3 reviews | Reviews shown in turn: name, text and when. |
| title | string | "Customer reviews" | Heading above the rating. |
| countLabel | string | "reviews" | Word after the review count. |
| locale | string | "en-US" | Locale used to format the numbers. |
| starColor | string | "#f5b400" | Color of the stars. |
| loop | boolean | true | Replay forever with a pause between runs. |
| className | string | "" | Extra classes for the outer element. |
Dependencies
- gsap
- @gsap/react