Commerce
Plan Toggle
The switch slides from monthly to yearly, the prices roll like an odometer and the discount badge pops in.
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(useGSAP);
export type Plan = {
/** Plan name. */
name: string;
/** Price per month on the monthly plan (whole number). */
monthly: number;
/** Price per month on the yearly plan (whole number). */
yearly: number;
/** Short list of what the plan includes. */
features: string[];
};
export type PlanToggleProps = {
/** Plans, in order. Three fit best. */
plans?: Plan[];
/** Index of the highlighted plan. */
featured?: number;
/** Badge on the highlighted plan. */
featuredLabel?: string;
/** Currency symbol before the price. */
currency?: string;
/** Text after the price. */
period?: string;
/** Labels of the two sides of the switch. */
labels?: [string, string];
/** Badge next to the yearly label. */
discount?: string;
/** Line under the price on each side of the switch. */
billing?: [string, string];
/** Color of the switch, the badge and the highlighted plan. */
accent?: string;
/** Replay forever with a pause between runs. */
loop?: boolean;
className?: string;
};
const DEFAULT_PLANS: Plan[] = [
{ name: "Starter", monthly: 29, yearly: 23, features: ["1 page", "Contact form", "Email support"] },
{ name: "Growth", monthly: 59, yearly: 47, features: ["5 pages", "WhatsApp button", "Monthly report"] },
{ name: "Pro", monthly: 99, yearly: 79, features: ["Unlimited pages", "Online booking", "Priority support"] },
];
// Coluna de um dígito: 0 a 9 e uma posição vazia (para preços com menos dígitos).
const COLUMN = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", ""];
const slotsOf = (n: number, width: number) => {
const s = String(n);
return Array.from({ length: width }, (_, i) => {
const ch = s[i - (width - s.length)];
return ch === undefined ? 10 : Number(ch);
});
};
export default function PlanToggle({
plans = DEFAULT_PLANS,
featured = 1,
featuredLabel = "Popular",
currency = "$",
period = "/mo",
labels = ["Monthly", "Yearly"],
discount = "-20%",
billing = ["billed monthly", "billed yearly"],
accent = "#1f5bff",
loop = true,
className = "",
}: PlanToggleProps) {
const root = useRef<HTMLDivElement>(null);
useGSAP(
() => {
const q = gsap.utils.selector(root);
const cols = q("[data-col]") as HTMLElement[];
// Deslocamento em px inteiros (altura medida de um dígito): em porcentagem, alguns dígitos paravam
// em meio pixel e ficavam desalinhados dos vizinhos.
const to = (side: "m" | "y") => (i: number) =>
-Number(cols[i].dataset[side]) * (cols[i].firstElementChild as HTMLElement).offsetHeight;
const flip = (tl: gsap.core.Timeline, side: "m" | "y", at: number) => {
const yearly = side === "y";
tl.to(q("[data-thumb]"), { xPercent: yearly ? 100 : 0, duration: 0.45, ease: "power3.inOut" }, at)
.to(q("[data-label='m']"), { color: yearly ? "#525252" : "#ffffff", duration: 0.3 }, at + 0.1)
.to(q("[data-label='y']"), { color: yearly ? "#ffffff" : "#525252", duration: 0.3 }, at + 0.1)
.to(cols, { y: to(side), duration: 0.7, ease: "power3.inOut" }, at + 0.1)
.to(q("[data-bill='m']"), { autoAlpha: yearly ? 0 : 1, duration: 0.3 }, at + 0.25)
.to(q("[data-bill='y']"), { autoAlpha: yearly ? 1 : 0, duration: 0.3 }, at + 0.25)
.to(q("[data-badge]"), yearly ? { scale: 1, autoAlpha: 1, duration: 0.4, ease: "back.out(2.4)" } : { scale: 0.6, autoAlpha: 0, duration: 0.25 }, at + (yearly ? 0.35 : 0));
};
// Vai para o anual e volta para o mensal: começa e termina no mesmo quadro, o loop não pula.
const tl = gsap.timeline({ repeat: loop ? -1 : 0, repeatDelay: 1.2 });
tl.set(q("[data-thumb]"), { xPercent: 0 })
.set(q("[data-label='m']"), { color: "#ffffff" })
.set(q("[data-label='y']"), { color: "#525252" })
.set(cols, { y: to("m") })
.set(q("[data-bill='m']"), { autoAlpha: 1 })
.set(q("[data-bill='y']"), { autoAlpha: 0 })
.set(q("[data-badge]"), { scale: 0.6, autoAlpha: 0 })
.to(q("[data-thumb]"), { scale: 0.94, duration: 0.1, yoyo: true, repeat: 1 }, 0.9);
flip(tl, "y", 1.0);
flip(tl, "m", 4.2);
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) tl.progress(1).pause();
},
{ scope: root, dependencies: [loop, plans.map((p) => `${p.monthly}-${p.yearly}`).join("|")] },
);
return (
<div ref={root} className={`@container w-[520px] max-w-full rounded-2xl bg-neutral-100 p-3 text-neutral-900 min-[480px]:p-5 ${className}`}>
<div className="flex items-center justify-center gap-2.5">
<div className="relative grid grid-cols-2 rounded-full bg-neutral-200 p-1 text-[13px] font-semibold">
<span
data-thumb
aria-hidden="true"
className="absolute inset-y-1 left-1 w-[calc(50%-4px)] rounded-full"
style={{ backgroundColor: accent }}
/>
<span data-label="m" className="relative px-4 py-1.5 text-center">
{labels[0]}
</span>
<span data-label="y" className="relative px-4 py-1.5 text-center">
{labels[1]}
</span>
</div>
<span
data-badge
className="rounded-full px-2 py-0.5 text-[11.5px] font-bold"
style={{ color: accent, backgroundColor: `color-mix(in srgb, ${accent} 14%, white)` }}
>
{discount}
</span>
</div>
<div className="mt-4 grid grid-cols-3 gap-1.5 @[460px]:gap-2.5">
{plans.map((plan, i) => {
const width = Math.max(String(plan.monthly).length, String(plan.yearly).length);
const m = slotsOf(plan.monthly, width);
const y = slotsOf(plan.yearly, width);
const hot = i === featured;
return (
<div
key={plan.name}
className="relative flex flex-col rounded-xl border-[1.5px] bg-white p-2.5 @[460px]:p-3.5"
style={{ borderColor: hot ? accent : "#e5e5e5", boxShadow: hot ? `0 10px 30px -12px ${accent}` : undefined }}
>
{hot ? (
<span
className="absolute -top-2.5 left-2.5 rounded-full @[460px]:left-3.5 px-2 py-0.5 text-[10.5px] font-bold text-white"
style={{ backgroundColor: accent }}
>
{featuredLabel}
</span>
) : null}
<p className="text-[13px] font-semibold text-neutral-600">{plan.name}</p>
<p className="mt-1.5 flex items-baseline gap-0.5" aria-label={`${currency}${plan.monthly}${period}`}>
<span className="text-[13px] font-semibold @[460px]:text-[16px]">{currency}</span>
<span aria-hidden="true" className="flex h-[26px] items-start overflow-hidden text-[22px] leading-[26px] font-bold tabular-nums tracking-tight @[460px]:h-[34px] @[460px]:text-[30px] @[460px]:leading-[34px]">
{m.map((d, k) => (
<span key={k} data-col data-m={d} data-y={y[k]} className="flex flex-col">
{COLUMN.map((c, j) => (
<span key={j} className="block h-[26px] text-center @[460px]:h-[34px]">
{c || " "}
</span>
))}
</span>
))}
</span>
<span className="text-[10.5px] text-neutral-500 @[460px]:text-[12px]">{period}</span>
</p>
<p className="grid text-[10px] text-neutral-500 @[460px]:text-[11px]">
<span data-bill="m" className="[grid-area:1/1]">
{billing[0]}
</span>
<span data-bill="y" className="[grid-area:1/1]">
{billing[1]}
</span>
</p>
<ul className="mt-3 space-y-1.5 text-[11px] text-neutral-700 @[460px]:text-[12px]">
{plan.features.map((f) => (
<li key={f} className="flex items-start gap-1.5">
<svg viewBox="0 0 24 24" className="mt-[1px] hidden h-3.5 w-3.5 shrink-0 @[460px]:block" fill="none" stroke={accent} strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M5 12.5l4.5 4.5L19 7.5" />
</svg>
{f}
</li>
))}
</ul>
</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 "Plan Toggle" 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 plans: names, monthly and yearly price per month, features, and which one is highlighted - the currency symbol, the period text and the discount badge - the accent color - the language of the texts 4. The animation What it looks like: A pricing block: a pill switch with "Monthly" and "Yearly" (a colored thumb behind the active side) and a small discount badge; below it three plan cards side by side, the middle one highlighted with a colored border and a "Popular" tag. Each card has the name, a big price with "/mo", a billing line and three features with check marks. Step by step: - Each price digit is a vertical column of 0–9 plus an empty slot (for prices with fewer digits) inside an overflow-hidden box one line tall. Give the line a whole-pixel height and move the column by y = digit × that height in px (not yPercent), so no digit stops half a pixel off its neighbors. - At 0.9 s the thumb gets a quick press (scale 0.94, yoyo); at 1.0 s it slides to Yearly (xPercent 100, 0.45 s, power3.inOut) and the label colors swap. - At 1.1 s every digit column rolls to the yearly price together (0.7 s, power3.inOut), the billing line cross-fades, and at 1.35 s the discount badge pops in (scale 0.6 → 1, back.out(2.4)). - At 4.2 s the same moves run back to Monthly, so the loop starts and ends on the same frame; pause 1.2 s. 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). - Prices are whole numbers so the odometer only rolls digits; give the price an aria-label, since the columns are hidden from screen readers. 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/plan-toggle.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 "Plan Toggle" 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 plans: names, monthly and yearly price per month, features, and which one is highlighted - the currency symbol, the period text and the discount badge - the accent color - the language of the texts 4. The animation What it looks like: A pricing block: a pill switch with "Monthly" and "Yearly" (a colored thumb behind the active side) and a small discount badge; below it three plan cards side by side, the middle one highlighted with a colored border and a "Popular" tag. Each card has the name, a big price with "/mo", a billing line and three features with check marks. Step by step: - Each price digit is a vertical column of 0–9 plus an empty slot (for prices with fewer digits) inside an overflow-hidden box one line tall. Give the line a whole-pixel height and move the column by y = digit × that height in px (not yPercent), so no digit stops half a pixel off its neighbors. - At 0.9 s the thumb gets a quick press (scale 0.94, yoyo); at 1.0 s it slides to Yearly (xPercent 100, 0.45 s, power3.inOut) and the label colors swap. - At 1.1 s every digit column rolls to the yearly price together (0.7 s, power3.inOut), the billing line cross-fades, and at 1.35 s the discount badge pops in (scale 0.6 → 1, back.out(2.4)). - At 4.2 s the same moves run back to Monthly, so the loop starts and ends on the same frame; pause 1.2 s. 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). - Prices are whole numbers so the odometer only rolls digits; give the price an aria-label, since the columns are hidden from screen readers. 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/plan-toggle.json`.
Install
npx shadcn@latest add https://motionshelf-pi.vercel.app/r/plan-toggle.jsonCopies the file into your project. Requires React 19, Tailwind CSS v4 and GSAP (installed as a dependency).
Props
| Name | Type | Default | Description |
|---|---|---|---|
| plans | Plan[] | 3 plans | Plans in order: name, monthly and yearly price per month (whole numbers), features. |
| featured | number | 1 | Index of the highlighted plan. |
| featuredLabel | string | "Popular" | Badge on the highlighted plan. |
| currency | string | "$" | Currency symbol before the price. |
| period | string | "/mo" | Text after the price. |
| labels | [string, string] | ["Monthly", "Yearly"] | Labels of the two sides of the switch. |
| discount | string | "-20%" | Badge next to the yearly label. |
| billing | [string, string] | ["billed monthly", "billed yearly"] | Line under the price on each side. |
| accent | string | "#1f5bff" | Color of the switch, the badge and the highlighted plan. |
| loop | boolean | true | Replay forever with a pause between runs. |
| className | string | "" | Extra classes for the outer element. |
Dependencies
- gsap
- @gsap/react