Skip to content

Pricing Plan Carousel

Pricing plan carousel for mobile: snap-scrolling fitness plans that become a row on desktop, with a trial timeline.

  • Pricing
  • Sports & fitness
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers (CSS scroll snap). :has() for the card focus ring (Chrome 105+, Safari 15.4+, Firefox 121+).

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/pricing-plan-carousel.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/pricing/pricing-plan-carousel.tsx
1/**
2 * PricingPlanCarousel, mobile-first plan picker for a fitness app. On phones
3 * the plans are a horizontal scroll-snap carousel (peeking neighbours, synced
4 * dots, the selected plan centred on load); from 900px they sit in a row.
5 * Below, the free trial is explained as a timeline: today you unlock
6 * everything, day 5 we remind you, day 7 you are charged the selected plan's
7 * price. Day ticks fill one by one when the timeline scrolls into view.
8 *
9 * Props: plans ({ id, name, price, period, perMonth?, badge?, features[],
10 * seats? }), defaultId, trialDays, reminderDay, title, lede, cta, fine.
11 * CSS vars: --fd-ppc-bg, --fd-ppc-fg, --fd-ppc-volt, --fd-ppc-ink.
12 *
13 * Browser support: all evergreen browsers (CSS scroll snap).
14 * Accessibility: plans are a radiogroup of real radio inputs (arrow keys
15 * work); dots are buttons that scroll to a plan; the timeline is an ordered
16 * list with the charge amount as text.
17 * Reduced motion: no tick sweep, smooth scrolling becomes instant.
18 */
19import { useEffect, useId, useRef, useState } from "react";
20
21export interface FitPlan {
22 id: string;
23 name: string;
24 price: number;
25 period: string;
26 perMonth?: string;
27 badge?: string;
28 features: string[];
29}
30
31export interface PricingPlanCarouselProps {
32 plans?: FitPlan[];
33 defaultId?: string;
34 trialDays?: number;
35 reminderDay?: number;
36 currency?: string;
37 title?: string;
38 lede?: string;
39 cta?: string;
40 fine?: string;
41 className?: string;
42}
43
44const PLANS: FitPlan[] = [
45 { id: "monthly", name: "Monthly", price: 14.99, period: "month", features: ["Adaptive run and strength plans", "Apple Watch, Garmin and Coros sync", "Cancel any month"] },
46 { id: "annual", name: "Annual", price: 79.99, period: "year", perMonth: "$6.67 a month", badge: "Most picked", features: ["Everything in Monthly", "Race-day pacing for 5K to marathon", "Coach chat, replies within 24 h", "Saves $99.89 vs monthly"] },
47 { id: "duo", name: "Duo", price: 119.99, period: "year", perMonth: "$5.00 each a month", features: ["Two accounts, one bill", "Shared streaks and weekly leaderboard", "Swap training partner once a year"] },
48];
49
50export function PricingPlanCarousel({
51 plans = PLANS,
52 defaultId = "annual",
53 trialDays = 7,
54 reminderDay = 5,
55 currency = "$",
56 title = "Seven days on us. Then pick your pace.",
57 lede = "Every plan unlocks all 340 sessions, from 12-minute mobility to 32 km long runs. You only choose how you pay.",
58 cta = "Start 7 days free",
59 fine = "Cancel in two taps: Settings, Membership, Cancel. No email, no phone call, no guilt trip.",
60 className,
61}: PricingPlanCarouselProps) {
62 const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
63 const [sel, setSel] = useState(defaultId);
64 const [active, setActive] = useState(Math.max(0, plans.findIndex((p) => p.id === defaultId)));
65 const [seen, setSeen] = useState(false);
66 const track = useRef<HTMLDivElement>(null);
67 const cards = useRef<(HTMLLabelElement | null)[]>([]);
68 const line = useRef<HTMLOListElement>(null);
69 const plan = plans.find((p) => p.id === sel) ?? plans[0];
70
71 const scrollTo = (i: number, smooth = true) => {
72 const t = track.current;
73 const c = cards.current[i];
74 if (!t || !c || t.scrollWidth <= t.clientWidth) return;
75 const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
76 t.scrollTo({ left: c.offsetLeft - (t.clientWidth - c.offsetWidth) / 2, behavior: smooth && !reduce ? "smooth" : "auto" });
77 };
78
79 useEffect(() => {
80 scrollTo(active, false);
81 const t = track.current;
82 if (!t) return;
83 let raf = 0;
84 const onScroll = () => {
85 cancelAnimationFrame(raf);
86 raf = requestAnimationFrame(() => {
87 const mid = t.scrollLeft + t.clientWidth / 2;
88 let best = 0;
89 let d = Infinity;
90 cards.current.forEach((c, i) => {
91 if (!c) return;
92 const dd = Math.abs(c.offsetLeft + c.offsetWidth / 2 - mid);
93 if (dd < d) {
94 d = dd;
95 best = i;
96 }
97 });
98 setActive(best);
99 });
100 };
101 t.addEventListener("scroll", onScroll, { passive: true });
102 return () => {
103 cancelAnimationFrame(raf);
104 t.removeEventListener("scroll", onScroll);
105 };
106 }, []); // eslint-disable-line react-hooks/exhaustive-deps
107
108 useEffect(() => {
109 const el = line.current;
110 if (!el) return;
111 if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
112 setSeen(true);
113 return;
114 }
115 const io = new IntersectionObserver(([e]) => {
116 if (e.isIntersecting) {
117 setSeen(true);
118 io.disconnect();
119 }
120 }, { threshold: 0.4 });
121 io.observe(el);
122 return () => io.disconnect();
123 }, []);
124
125 const price = `${currency}${plan.price.toFixed(2)}`;
126 const days = Array.from({ length: trialDays + 1 }, (_, i) => i);
127
128 return (
129 <section className={`fd-ppc${className ? " " + className : ""}`} aria-labelledby={`${uid}-t`}>
130 <style>{css}</style>
131 <div className="fd-ppc-head">
132 <h2 className="fd-ppc-title" id={`${uid}-t`}>{title}</h2>
133 <p className="fd-ppc-lede">{lede}</p>
134 </div>
135
136 <div className="fd-ppc-track" ref={track} role="radiogroup" aria-label="Plans">
137 {plans.map((p, i) => (
138 <label
139 key={p.id}
140 className="fd-ppc-card"
141 data-on={p.id === sel ? "" : undefined}
142 ref={(el) => {
143 cards.current[i] = el;
144 }}
145 >
146 <input
147 type="radio"
148 name={`${uid}-plan`}
149 value={p.id}
150 checked={p.id === sel}
151 onChange={() => {
152 setSel(p.id);
153 scrollTo(i);
154 }}
155 />
156 <span className="fd-ppc-top">
157 <span className="fd-ppc-name">{p.name}</span>
158 {p.badge && <span className="fd-ppc-badge">{p.badge}</span>}
159 <span className="fd-ppc-tick" aria-hidden="true">
160 <svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M2.5 7.4l3 3 6-6.6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" /></svg>
161 </span>
162 </span>
163 <span className="fd-ppc-price">
164 <b>{currency}{Math.floor(p.price)}</b>
165 <i>.{(p.price % 1).toFixed(2).slice(2)}</i>
166 <em>/ {p.period}</em>
167 </span>
168 <span className="fd-ppc-pm">{p.perMonth ?? "Billed monthly"}</span>
169 <span className="fd-ppc-feats">
170 {p.features.map((f) => (
171 <span key={f}>{f}</span>
172 ))}
173 </span>
174 </label>
175 ))}
176 </div>
177 <div className="fd-ppc-dots">
178 {plans.map((p, i) => (
179 <button key={p.id} type="button" aria-label={`Show ${p.name} plan`} aria-current={i === active ? "true" : undefined} onClick={() => scrollTo(i)} />
180 ))}
181 </div>
182
183 <div className="fd-ppc-trial">
184 <p className="fd-ppc-trial-h">How the free week works</p>
185 <ol className="fd-ppc-line" ref={line} data-seen={seen ? "" : undefined}>
186 <li className="fd-ppc-days" aria-hidden="true">
187 {days.map((d) => (
188 <span
189 key={d}
190 data-k={d === 0 ? "start" : d === reminderDay ? "remind" : d === trialDays ? "charge" : undefined}
191 style={{ ["--p" as string]: `${d <= reminderDay ? (d / reminderDay) * 50 : 50 + ((d - reminderDay) / (trialDays - reminderDay)) * 50}%`, transitionDelay: `${d * 90}ms` }}
192 />
193 ))}
194 </li>
195 <li className="fd-ppc-step" style={{ ["--at" as string]: 0 }}>
196 <span className="fd-ppc-when">Today</span>
197 <span className="fd-ppc-what">Everything unlocks. Your first plan builds from a 2-minute run test.</span>
198 </li>
199 <li className="fd-ppc-step" style={{ ["--at" as string]: reminderDay / trialDays }}>
200 <span className="fd-ppc-when">Day {reminderDay}</span>
201 <span className="fd-ppc-what">We remind you by push and email, 48 hours before anything is charged.</span>
202 </li>
203 <li className="fd-ppc-step fd-ppc-charge" style={{ ["--at" as string]: 1 }}>
204 <span className="fd-ppc-when">Day {trialDays}</span>
205 <span className="fd-ppc-what">
206 You pay <b key={plan.id}>{price}</b> for {plan.name.toLowerCase()}. Cancel before and you pay nothing.
207 </span>
208 </li>
209 </ol>
210 <div className="fd-ppc-act">
211 <a className="fd-ppc-cta" href="#trial">
212 {cta}
213 <span>then {price} / {plan.period}</span>
214 </a>
215 <p className="fd-ppc-fine">{fine}</p>
216 </div>
217 </div>
218 </section>
219 );
220}
221
222const css = `
223:where(.fd-ppc,.fd-ppc *){box-sizing:border-box;margin:0;padding:0;border:0;background:none;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none}
224:where(.fd-ppc,.fd-ppc *)::before,:where(.fd-ppc,.fd-ppc *)::after{box-sizing:border-box}
225:where(.fd-ppc svg){display:block;flex-shrink:0}
226:where(.fd-ppc button){appearance:none;-webkit-appearance:none;cursor:pointer}
227.fd-ppc{
228 --fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-ease-in-out:cubic-bezier(.65,0,.35,1);--fd-spring:cubic-bezier(.34,1.56,.64,1);
229 --fd-dur-1:120ms;--fd-dur-2:240ms;--fd-dur-3:420ms;--fd-dur-4:700ms;
230 --fd-ppc-bg:#1B2BF0;--fd-ppc-fg:#FFFFFF;--fd-ppc-volt:#DFFF3A;--fd-ppc-ink:#0B0C1A;
231 --mut:rgba(255,255,255,.72);--line:rgba(255,255,255,.22);
232 --sans:ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Display","SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;
233 display:block;width:100%;overflow:hidden;background:var(--fd-ppc-bg);color:var(--fd-ppc-fg);font:400 15px/22px var(--sans);-webkit-font-smoothing:antialiased;
234 padding:clamp(40px,7vw,96px) 0 clamp(40px,6vw,80px)}
235.fd-ppc-head,.fd-ppc-trial{max-width:1200px;margin:0 auto;padding:0 clamp(16px,4vw,56px)}
236.fd-ppc-head{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(0,1fr);gap:20px 56px;align-items:end}
237.fd-ppc-title{font:900 clamp(2.5rem,5.6vw,4.75rem)/.92 var(--sans);font-style:italic;letter-spacing:-.045em;text-transform:uppercase}
238.fd-ppc-lede{max-width:40ch;font-size:1rem;line-height:24px;color:var(--mut)}
239
240.fd-ppc-track{max-width:1200px;margin:clamp(32px,5vw,56px) auto 0;padding:8px clamp(16px,4vw,56px) 4px;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}
241.fd-ppc-card{position:relative;display:flex;flex-direction:column;min-height:340px;padding:22px;border-radius:24px;cursor:pointer;color:var(--fd-ppc-fg);box-shadow:inset 0 0 0 1.5px var(--line);transition:background-color var(--fd-dur-2) var(--fd-ease-in-out),color var(--fd-dur-2),box-shadow var(--fd-dur-2),transform var(--fd-dur-2) var(--fd-spring)}
242.fd-ppc-card:hover{box-shadow:inset 0 0 0 1.5px rgba(255,255,255,.6)}
243.fd-ppc-card:active{transform:scale(.97);transition-duration:var(--fd-dur-1)}
244.fd-ppc-card[data-on]{background:var(--fd-ppc-volt);color:var(--fd-ppc-ink);box-shadow:none}
245.fd-ppc-card input{position:absolute;opacity:0;width:1px;height:1px;margin:0;pointer-events:none}
246.fd-ppc-card:has(input:focus-visible){outline:3px solid var(--fd-ppc-volt);outline-offset:3px}
247.fd-ppc-top{display:flex;align-items:center;gap:8px}
248.fd-ppc-name{font:900 1.375rem/28px var(--sans);font-style:italic;letter-spacing:-.03em;text-transform:uppercase}
249.fd-ppc-badge{padding:3px 8px;border-radius:9999px;background:var(--fd-ppc-ink);color:var(--fd-ppc-volt);font:800 10px/14px var(--sans);letter-spacing:.08em;text-transform:uppercase}
250.fd-ppc-card:not([data-on]) .fd-ppc-badge{background:var(--fd-ppc-volt);color:var(--fd-ppc-ink)}
251.fd-ppc-tick{display:grid;place-items:center;width:28px;height:28px;margin-left:auto;border-radius:50%;box-shadow:inset 0 0 0 1.5px var(--line);color:transparent;transition:background-color var(--fd-dur-2),color var(--fd-dur-2),transform var(--fd-dur-3) var(--fd-spring)}
252.fd-ppc-card[data-on] .fd-ppc-tick{background:var(--fd-ppc-ink);color:var(--fd-ppc-volt);box-shadow:none;transform:rotate(-8deg) scale(1.08)}
253.fd-ppc-price{display:flex;align-items:baseline;padding-top:48px}
254.fd-ppc-price b{font:900 clamp(3.5rem,6vw,4.75rem)/1 var(--sans);font-style:italic;letter-spacing:-.06em;font-variant-numeric:tabular-nums}
255.fd-ppc-price i{font:900 1.5rem/1 var(--sans);font-style:italic;letter-spacing:-.03em}
256.fd-ppc-price em{margin-left:8px;font:600 14px/20px var(--sans);font-style:normal;opacity:.75}
257.fd-ppc-pm{margin:4px 0 24px;font:600 13px/18px var(--sans);opacity:.75}
258.fd-ppc-feats{display:flex;flex-direction:column;gap:6px;margin-top:auto;padding-top:16px;border-top:1.5px solid currentColor;border-top-color:color-mix(in srgb,currentColor 25%,transparent)}
259.fd-ppc-feats span{position:relative;padding-left:18px;font:500 14px/20px var(--sans)}
260.fd-ppc-feats span::before{content:"";position:absolute;left:0;top:8px;width:9px;height:4px;background:currentColor;transform:skewX(-20deg)}
261.fd-ppc-dots{display:none;justify-content:center;gap:8px;margin-top:18px}
262.fd-ppc-dots button{width:8px;height:8px;border-radius:9999px;background:var(--line);transition:width var(--fd-dur-2) var(--fd-ease-out),background-color var(--fd-dur-2)}
263.fd-ppc-dots button[aria-current]{width:28px;background:var(--fd-ppc-volt)}
264.fd-ppc-dots button:focus-visible{outline:2px solid var(--fd-ppc-volt);outline-offset:3px}
265
266.fd-ppc-trial{margin-top:clamp(40px,6vw,72px)}
267.fd-ppc-trial-h{font:800 12px/16px var(--sans);letter-spacing:.14em;text-transform:uppercase;color:var(--fd-ppc-volt)}
268.fd-ppc-line{position:relative;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px;margin-top:20px;padding-top:44px}
269.fd-ppc-days{position:absolute;left:14px;right:14px;top:0;display:block;height:28px}
270.fd-ppc-days::before{content:"";position:absolute;left:0;right:0;top:50%;height:2px;margin-top:-1px;background:var(--line)}
271.fd-ppc-days span{position:absolute;left:var(--p);top:50%;margin:-5px 0 0 -5px;width:10px;height:10px;border-radius:50%;background:var(--fd-ppc-bg);box-shadow:inset 0 0 0 2px var(--line);transition:background-color var(--fd-dur-2),box-shadow var(--fd-dur-2),transform var(--fd-dur-3) var(--fd-spring)}
272.fd-ppc-line[data-seen] .fd-ppc-days span{background:var(--fd-ppc-fg);box-shadow:none}
273.fd-ppc-days span[data-k]{width:28px;height:28px;margin:-14px 0 0 -14px}
274.fd-ppc-line[data-seen] .fd-ppc-days span[data-k]{transform:scale(1)}
275.fd-ppc-days span[data-k]::after{content:"";position:absolute;inset:8px;border-radius:50%;background:var(--fd-ppc-bg)}
276.fd-ppc-line[data-seen] .fd-ppc-days span[data-k="charge"]{background:var(--fd-ppc-volt)}
277.fd-ppc-days span[data-k="charge"]::after{background:var(--fd-ppc-ink)}
278.fd-ppc-step{display:flex;flex-direction:column;gap:6px}
279.fd-ppc-step:nth-child(3){text-align:center;align-items:center}
280.fd-ppc-step:nth-child(4){text-align:right;align-items:flex-end}
281.fd-ppc-when{font:900 1.25rem/24px var(--sans);font-style:italic;letter-spacing:-.02em;text-transform:uppercase}
282.fd-ppc-what{max-width:30ch;font-size:.875rem;line-height:21px;color:var(--mut)}
283.fd-ppc-what b{display:inline-block;padding:0 6px;border-radius:6px;background:var(--fd-ppc-volt);color:var(--fd-ppc-ink);font-weight:800;animation:fd-ppc-pop var(--fd-dur-3) var(--fd-spring)}
284@keyframes fd-ppc-pop{from{transform:scale(.6) rotate(-6deg)}to{transform:none}}
285.fd-ppc-act{display:flex;flex-wrap:wrap;align-items:center;gap:16px 28px;margin-top:40px;padding-top:28px;border-top:1.5px solid var(--line)}
286.fd-ppc-cta{display:inline-flex;flex-direction:column;justify-content:center;min-width:260px;height:64px;padding:0 28px;border-radius:9999px;background:var(--fd-ppc-volt);color:var(--fd-ppc-ink);font:900 1.25rem/24px var(--sans);font-style:italic;letter-spacing:-.02em;text-transform:uppercase;transition:transform var(--fd-dur-2) var(--fd-spring),background-color var(--fd-dur-1)}
287.fd-ppc-cta span{font:600 12px/16px var(--sans);font-style:normal;letter-spacing:0;text-transform:none;opacity:.75}
288.fd-ppc-cta:hover{background:#fff}
289.fd-ppc-cta:active{transform:scale(.96);transition-duration:var(--fd-dur-1)}
290.fd-ppc-cta:focus-visible{outline:3px solid #fff;outline-offset:3px}
291.fd-ppc-fine{flex:1;min-width:220px;max-width:46ch;font-size:13px;line-height:20px;color:var(--mut)}
292
293@media (max-width:899px){
294 .fd-ppc-head{grid-template-columns:1fr}
295 .fd-ppc-track{display:flex;gap:12px;overflow-x:auto;scroll-snap-type:x mandatory;scroll-padding:0 16px;-webkit-overflow-scrolling:touch;scrollbar-width:none;padding-left:16px;padding-right:16px}
296 .fd-ppc-track::-webkit-scrollbar{display:none}
297 .fd-ppc-card{flex:0 0 min(82%,340px);scroll-snap-align:center;min-height:360px}
298 .fd-ppc-dots{display:flex}
299 .fd-ppc-line{grid-template-columns:1fr;gap:28px;padding-top:0;padding-left:48px}
300 .fd-ppc-days{left:0;right:auto;top:14px;bottom:14px;width:28px;height:auto}
301 .fd-ppc-days span{left:50%;top:var(--p)}
302 .fd-ppc-days::before{left:50%;right:auto;top:0;bottom:0;width:2px;height:auto;margin:0 0 0 -1px}
303 .fd-ppc-step:nth-child(3),.fd-ppc-step:nth-child(4){text-align:left;align-items:flex-start}
304 .fd-ppc-cta{width:100%}
305}
306@media (prefers-reduced-motion:reduce){.fd-ppc *{transition-duration:1ms!important;transition-delay:0ms!important;animation:none!important}}
307@media (prefers-contrast:more){.fd-ppc{--line:#fff;--mut:#fff}}
308`;
309
310export function Demo() {
311 return (
312 <div className="fd-ppcd">
313 <style>{demoCss}</style>
314 <header className="fd-ppcd-nav">
315 <a href="#home" className="fd-ppcd-logo">
316 <svg width="26" height="16" viewBox="0 0 26 16" aria-hidden="true">
317 <path d="M4 0h22l-4 16H0z" fill="#DFFF3A" />
318 <path d="M9 4h12M7 8h12M5 12h8" stroke="#1B2BF0" strokeWidth="2" />
319 </svg>
320 HARDPACE
321 </a>
322 <a href="#login" className="fd-ppcd-in">Log in</a>
323 </header>
324 <PricingPlanCarousel />
325 </div>
326 );
327}
328
329const demoCss = `
330:where(.fd-ppcd,.fd-ppcd *){box-sizing:border-box;margin:0;padding:0;font:inherit;color:inherit;text-decoration:none}
331.fd-ppcd{min-height:100vh;background:#1B2BF0;color:#fff;font:400 14px/20px ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif}
332.fd-ppcd-nav{display:flex;align-items:center;justify-content:space-between;height:60px;padding:0 clamp(16px,4vw,56px);border-bottom:1.5px solid rgba(255,255,255,.18)}
333.fd-ppcd-logo{display:flex;align-items:center;gap:10px;font-weight:900;font-style:italic;font-size:18px;letter-spacing:-.02em}
334.fd-ppcd-logo svg{display:block}
335.fd-ppcd-in{font-weight:700}
336`;

More Pricing. Same file contract.

Pricing Tiers Toggle, pricing preview: React pricing section with three plans, a monthly and yearly toggle, odometer prices and a set-apart…

Pricing Tiers Toggle

React pricing section with three plans, a monthly and yearly toggle, odometer prices and a set-apart recommended plan.

Usage Calculator, pricing preview: Usage-based pricing calculator: snapping sliders price every plan at once, flag the cheapest and animate an…

Usage Calculator

Usage-based pricing calculator: snapping sliders price every plan at once, flag the cheapest and animate an itemised bill.

Membership Card, pricing preview: Single plan pricing as a membership card: spring 3D tilt, flips over, with honest included and excluded lists

Membership Card

Single plan pricing as a membership card: spring 3D tilt, flips over, with honest included and excluded lists.

Questions about Pricing Plan Carousel

Run npx shadcn@latest add https://freedesignmd.com/r/pricing-plan-carousel.json, or open the page and copy the file into your project. It has zero dependencies besides React. What you copy is the exact file that renders the live demo.

Pricing plan carousel for mobile: snap-scrolling fitness plans that become a row on desktop, with a trial timeline. It is a free, MIT-licensed React pricing component from the freedesignmd library.

Yes. It is free and MIT-licensed, use, modify and ship it in personal and commercial projects without attribution.

All evergreen browsers (CSS scroll snap). :has() for the card focus ring (Chrome 105+, Safari 15.4+, Firefox 121+).

Yes. It is a plain React component with its own styles, so it runs in Next.js, Vite, Remix and Lovable projects, and it needs no Tailwind setup. It has zero dependencies besides React.