Skip to content

Seat Stepper Pricing

Per-seat pricing stepper with hold-to-accelerate, a volume-discount scale, next price break flag and a sales handoff past 250.

  • Pricing
  • SaaS
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers. color-mix() for the hint tint (Chrome 111+, Safari 16.2+, Firefox 113+).

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/seat-stepper-pricing.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/pricing/seat-stepper-pricing.tsx
1/**
2 * SeatStepperPricing, per-seat volume pricing for a team tool. A seat
3 * stepper (buttons with press-and-hold acceleration, or type a number)
4 * moves a marker along a visible volume-discount scale; crossing a break
5 * re-prices every seat and flashes the tier. Monthly / annual switch. The
6 * summary points out the next break ("add 6 seats and pay $84 less") and,
7 * past the sales threshold, turns into a talk-to-sales card with a named
8 * person.
9 *
10 * Props: tiers ({ from, monthly, annual }[]), salesFrom, defaultSeats,
11 * defaultAnnual, title, lede, contact ({ name, role, photo, reply }), currency.
12 * CSS vars: --fd-ssp-bg, --fd-ssp-ink, --fd-ssp-signal.
13 *
14 * Browser support: all evergreen browsers.
15 * Accessibility: the input is a labelled spinbutton (ArrowUp/Down, PageUp/
16 * PageDown by 10, Home/End); +/- have aria-labels; billing is a role="switch".
17 * The total and tier changes are announced in a polite live region. The scale
18 * is described in a visually hidden list.
19 * Reduced motion: no marker spring, totals jump.
20 */
21import { useEffect, useId, useRef, useState } from "react";
22import type { KeyboardEvent } from "react";
23
24export interface SeatTier {
25 from: number;
26 monthly: number;
27 annual: number;
28}
29
30export interface SeatStepperPricingProps {
31 tiers?: SeatTier[];
32 salesFrom?: number;
33 defaultSeats?: number;
34 defaultAnnual?: boolean;
35 title?: string;
36 lede?: string;
37 currency?: string;
38 contact?: { name: string; role: string; photo: string; reply: string; href: string };
39 className?: string;
40}
41
42const TIERS: SeatTier[] = [
43 { from: 1, monthly: 14, annual: 12 },
44 { from: 10, monthly: 12, annual: 10 },
45 { from: 50, monthly: 10, annual: 8.5 },
46 { from: 100, monthly: 8.5, annual: 7 },
47];
48
49const SCALE_MAX = 400;
50const pos = (n: number) => Math.min(1, Math.log(Math.max(1, n)) / Math.log(SCALE_MAX));
51const fmt = (n: number, cur: string) => cur + n.toLocaleString("en-US", { minimumFractionDigits: n % 1 ? 2 : 0, maximumFractionDigits: 2 });
52
53function useTween(target: number) {
54 const [v, setV] = useState(target);
55 const cur = useRef(target);
56 useEffect(() => {
57 if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
58 cur.current = target;
59 setV(target);
60 return;
61 }
62 const a = cur.current;
63 const t0 = performance.now();
64 let raf = 0;
65 const tick = (now: number) => {
66 const t = Math.min(1, (now - t0) / 420);
67 const e = 1 - Math.pow(1 - t, 3);
68 cur.current = a + (target - a) * e;
69 setV(cur.current);
70 if (t < 1) raf = requestAnimationFrame(tick);
71 };
72 raf = requestAnimationFrame(tick);
73 return () => cancelAnimationFrame(raf);
74 }, [target]);
75 return v;
76}
77
78export function SeatStepperPricing({
79 tiers = TIERS,
80 salesFrom = 251,
81 defaultSeats = 42,
82 defaultAnnual = true,
83 title = "Every seat gets cheaper as your team grows.",
84 lede = "One plan, priced per person. Hit a break and the lower price applies to every seat, not only the new ones.",
85 currency = "$",
86 contact = { name: "Ines Moreau", role: "Head of Sales, EMEA", photo: "/img/people/p3.webp", reply: "Replies within 4 working hours", href: "#sales" },
87 className,
88}: SeatStepperPricingProps) {
89 const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
90 const [seats, setSeats] = useState(defaultSeats);
91 const [draft, setDraft] = useState(String(defaultSeats));
92 const [annual, setAnnual] = useState(defaultAnnual);
93 const [flash, setFlash] = useState(0);
94 const hold = useRef<{ t?: ReturnType<typeof setTimeout>; n: number }>({ n: 0 });
95
96 const tierIdx = tiers.reduce((acc, t, i) => (seats >= t.from ? i : acc), 0);
97 const tier = tiers[tierIdx];
98 const sales = seats >= salesFrom;
99 const per = annual ? tier.annual : tier.monthly;
100 const monthTotal = per * seats;
101 const total = annual ? monthTotal * 12 : monthTotal;
102 const shown = useTween(sales ? 0 : total);
103 const prevTier = useRef(tierIdx);
104 useEffect(() => {
105 if (prevTier.current !== tierIdx) setFlash((f) => f + 1);
106 prevTier.current = tierIdx;
107 }, [tierIdx]);
108
109 const next = tiers[tierIdx + 1];
110 let hint = "";
111 if (next && !sales) {
112 const add = next.from - seats;
113 const nextPer = annual ? next.annual : next.monthly;
114 const nextMonth = nextPer * next.from;
115 const diff = monthTotal - nextMonth;
116 hint =
117 diff > 0
118 ? `Add ${add} seat${add > 1 ? "s" : ""} and you'd pay ${fmt(diff * (annual ? 12 : 1), currency)} less ${annual ? "a year" : "a month"}.`
119 : `${add} more seat${add > 1 ? "s" : ""} and every seat drops to ${fmt(nextPer, currency)}.`;
120 } else if (!sales) {
121 hint = `Past ${salesFrom - 1} seats we'll quote you directly.`;
122 }
123
124 const set = (n: number) => {
125 const v = Math.max(1, Math.min(1000, Math.round(n)));
126 setSeats(v);
127 setDraft(String(v));
128 };
129 const startHold = (d: number) => {
130 set(seats + d);
131 let s = seats + d;
132 hold.current.n = 0;
133 const loop = () => {
134 hold.current.n++;
135 const step = hold.current.n > 18 ? 10 : hold.current.n > 8 ? 5 : 1;
136 s = Math.max(1, Math.min(1000, s + d * step));
137 setSeats(s);
138 setDraft(String(s));
139 hold.current.t = setTimeout(loop, hold.current.n > 4 ? 70 : 140);
140 };
141 hold.current.t = setTimeout(loop, 380);
142 };
143 const stopHold = () => {
144 if (hold.current.t) clearTimeout(hold.current.t);
145 hold.current.t = undefined;
146 };
147 useEffect(() => stopHold, []);
148
149 const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
150 const map: Record<string, number> = { ArrowUp: 1, ArrowDown: -1, PageUp: 10, PageDown: -10 };
151 if (e.key in map) {
152 e.preventDefault();
153 set(seats + map[e.key]);
154 } else if (e.key === "Home") {
155 e.preventDefault();
156 set(1);
157 } else if (e.key === "End") {
158 e.preventDefault();
159 set(salesFrom);
160 } else if (e.key === "Enter") {
161 set(Number(draft) || 1);
162 }
163 };
164
165 const bounds = [...tiers.map((t) => t.from), salesFrom, SCALE_MAX];
166
167 return (
168 <section className={`fd-ssp${className ? " " + className : ""}`} aria-labelledby={`${uid}-t`}>
169 <style>{css}</style>
170 <div className="fd-ssp-wrap">
171 <div className="fd-ssp-main">
172 <p className="fd-ssp-eyebrow">
173 <span className="fd-ssp-sq" aria-hidden="true" /> Plinth Team plan
174 </p>
175 <h2 className="fd-ssp-title" id={`${uid}-t`}>{title}</h2>
176 <p className="fd-ssp-lede">{lede}</p>
177
178 <div className="fd-ssp-controls">
179 <div className="fd-ssp-stepper">
180 <label className="fd-ssp-lbl" htmlFor={`${uid}-n`}>Seats</label>
181 <div className="fd-ssp-step">
182 <button type="button" className="fd-ssp-btn" aria-label="Remove a seat" disabled={seats <= 1} onPointerDown={(e) => { e.preventDefault(); startHold(-1); }} onPointerUp={stopHold} onPointerLeave={stopHold} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); set(seats - 1); } }}>
183 <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8h10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /></svg>
184 </button>
185 <input
186 id={`${uid}-n`}
187 className="fd-ssp-input"
188 type="text"
189 inputMode="numeric"
190 role="spinbutton"
191 aria-valuenow={seats}
192 aria-valuemin={1}
193 aria-valuemax={1000}
194 value={draft}
195 onChange={(e) => {
196 const v = e.target.value.replace(/[^0-9]/g, "").slice(0, 4);
197 setDraft(v);
198 if (v) setSeats(Math.max(1, Math.min(1000, Number(v))));
199 }}
200 onBlur={() => set(Number(draft) || 1)}
201 onKeyDown={onKey}
202 />
203 <button type="button" className="fd-ssp-btn" aria-label="Add a seat" onPointerDown={(e) => { e.preventDefault(); startHold(1); }} onPointerUp={stopHold} onPointerLeave={stopHold} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); set(seats + 1); } }}>
204 <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8h10M8 3v10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /></svg>
205 </button>
206 </div>
207 <p className="fd-ssp-holdhint">Hold to count faster</p>
208 </div>
209
210 <button type="button" role="switch" aria-checked={annual} className="fd-ssp-switch" onClick={() => setAnnual((a) => !a)}>
211 <span className="fd-ssp-track" aria-hidden="true"><span /></span>
212 <span className="fd-ssp-sw-t">
213 Billed annually
214 <small>{annual ? "2 months free" : "Switch to save ~17%"}</small>
215 </span>
216 </button>
217 </div>
218
219 <div className="fd-ssp-scale" style={{ ["--x" as string]: pos(Math.min(seats, SCALE_MAX)) }}>
220 <div className="fd-ssp-bar" aria-hidden="true">
221 {bounds.slice(0, -1).map((b, i) => {
222 const l = pos(b);
223 const r = pos(bounds[i + 1]);
224 const isSales = i === tiers.length;
225 const active = sales ? isSales : i === tierIdx;
226 const passed = !isSales && i < tierIdx;
227 return (
228 <span key={b} className="fd-ssp-seg" data-on={active ? "" : undefined} data-past={passed || (sales && !isSales) ? "" : undefined} data-sales={isSales ? "" : undefined} style={{ left: `${l * 100}%`, width: `${(r - l) * 100}%` }}>
229 {active && <i key={flash} className="fd-ssp-sweep" />}
230 <b>{isSales ? "Sales" : fmt(annual ? tiers[i].annual : tiers[i].monthly, currency)}</b>
231 <em>{isSales ? `${salesFrom}+` : `${b}${tiers[i + 1] ? `–${tiers[i + 1].from - 1}` : `–${salesFrom - 1}`}`}</em>
232 </span>
233 );
234 })}
235 <span className="fd-ssp-marker" data-over={seats > SCALE_MAX ? "" : undefined}>
236 <span>{seats}</span>
237 </span>
238 </div>
239 <ul className="fd-ssp-sr">
240 {tiers.map((t, i) => (
241 <li key={t.from}>
242 {t.from} to {(tiers[i + 1]?.from ?? salesFrom) - 1} seats: {fmt(annual ? t.annual : t.monthly, currency)} per seat per month
243 </li>
244 ))}
245 <li>{salesFrom} seats or more: custom pricing</li>
246 </ul>
247 </div>
248 </div>
249
250 <aside className="fd-ssp-sum" data-sales={sales ? "" : undefined} aria-label="Your price">
251 {!sales ? (
252 <>
253 <p className="fd-ssp-sum-l">Per seat, per month</p>
254 <p className="fd-ssp-per" key={`${per}`}>
255 {fmt(per, currency)}
256 </p>
257 <dl className="fd-ssp-math">
258 <div>
259 <dt>Seats</dt>
260 <dd>× {seats}</dd>
261 </div>
262 {annual && (
263 <div>
264 <dt>Months</dt>
265 <dd>× 12</dd>
266 </div>
267 )}
268 <div className="fd-ssp-tot">
269 <dt>{annual ? "Per year" : "Per month"}</dt>
270 <dd>{currency}{Math.round(shown).toLocaleString("en-US")}</dd>
271 </div>
272 </dl>
273 {hint && <p className="fd-ssp-hint">{hint}</p>}
274 <a className="fd-ssp-cta" href="#start">Start with {seats} seat{seats > 1 ? "s" : ""}</a>
275 <p className="fd-ssp-fine">Add or remove people any time; we prorate to the day. Prices exclude VAT.</p>
276 </>
277 ) : (
278 <div className="fd-ssp-sales">
279 <p className="fd-ssp-sum-l">{seats} seats</p>
280 <p className="fd-ssp-sales-t">At this size, let&apos;s talk.</p>
281 <p className="fd-ssp-sales-d">Teams over {salesFrom - 1} get a custom rate, SSO and audit logs at no extra cost, and a migration plan for your old wiki.</p>
282 <div className="fd-ssp-person">
283 <img src={contact.photo} width={48} height={48} alt="" loading="lazy" />
284 <span>
285 <b>{contact.name}</b>
286 {contact.role}
287 <small>{contact.reply}</small>
288 </span>
289 </div>
290 <a className="fd-ssp-cta" href={contact.href}>Talk to sales</a>
291 </div>
292 )}
293 </aside>
294 </div>
295 <div className="fd-ssp-sr" role="status" aria-live="polite">
296 {sales ? `${seats} seats: custom pricing, talk to sales.` : `${seats} seats at ${fmt(per, currency)} each, ${currency}${total.toLocaleString("en-US")} ${annual ? "per year" : "per month"}.`}
297 </div>
298 </section>
299 );
300}
301
302const css = `
303:where(.fd-ssp,.fd-ssp *){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}
304:where(.fd-ssp,.fd-ssp *)::before,:where(.fd-ssp,.fd-ssp *)::after{box-sizing:border-box}
305:where(.fd-ssp svg,.fd-ssp img){display:block;flex-shrink:0}
306:where(.fd-ssp button){appearance:none;-webkit-appearance:none;cursor:pointer;text-align:left}
307.fd-ssp{
308 --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);
309 --fd-dur-1:120ms;--fd-dur-2:240ms;--fd-dur-3:420ms;--fd-dur-4:700ms;
310 --fd-ssp-bg:#FAFAF8;--fd-ssp-ink:#111110;--fd-ssp-signal:#FF4D00;
311 --mut:#6F6F6A;--line:#E3E3DE;--soft:#EFEFEB;
312 --sans:ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;
313 display:block;width:100%;background:var(--fd-ssp-bg);color:var(--fd-ssp-ink);font:400 15px/22px var(--sans);-webkit-font-smoothing:antialiased;
314 padding:clamp(48px,7vw,96px) clamp(16px,4vw,56px)}
315.fd-ssp-wrap{max-width:1200px;margin:0 auto;display:grid;grid-template-columns:minmax(0,1fr) 380px;gap:clamp(32px,5vw,80px);align-items:start}
316.fd-ssp-eyebrow{display:flex;align-items:center;gap:8px;font:600 13px/20px var(--sans)}
317.fd-ssp-sq{display:block;width:10px;height:10px;background:var(--fd-ssp-signal)}
318.fd-ssp-title{margin-top:16px;max-width:17ch;font:650 clamp(2.25rem,4.6vw,3.75rem)/1.02 var(--sans);font-weight:650;letter-spacing:-.045em}
319.fd-ssp-lede{margin-top:18px;max-width:50ch;font-size:1.0625rem;line-height:26px;color:var(--mut)}
320.fd-ssp-controls{display:flex;flex-wrap:wrap;align-items:flex-end;gap:24px 40px;margin-top:44px}
321.fd-ssp-lbl{display:block;font:500 13px/18px var(--sans);color:var(--mut)}
322.fd-ssp-step{display:flex;align-items:center;gap:4px;margin-top:8px;padding:4px;border-radius:16px;background:#fff;box-shadow:0 0 0 1px var(--line),0 1px 2px rgba(0,0,0,.04)}
323.fd-ssp-btn{display:grid;place-items:center;width:52px;height:52px;border-radius:12px;color:var(--fd-ssp-ink);background:var(--soft);transition:background-color var(--fd-dur-1),transform var(--fd-dur-2) var(--fd-spring);touch-action:manipulation;user-select:none;-webkit-user-select:none}
324.fd-ssp-btn:hover{background:#E4E4DF}
325.fd-ssp-btn:active{transform:scale(.96);transition-duration:var(--fd-dur-1)}
326.fd-ssp-btn:disabled{opacity:.35;cursor:not-allowed}
327.fd-ssp-btn:focus-visible,.fd-ssp-input:focus-visible,.fd-ssp-switch:focus-visible,.fd-ssp-cta:focus-visible{outline:2px solid color-mix(in srgb,var(--fd-ssp-signal) 70%,transparent);outline-offset:2px}
328.fd-ssp-input{width:112px;height:52px;border-radius:10px;text-align:center;font:700 2rem/52px var(--sans);letter-spacing:-.04em;font-variant-numeric:tabular-nums;color:var(--fd-ssp-ink);background:transparent;outline:none}
329.fd-ssp-holdhint{margin-top:8px;font:400 12px/16px var(--sans);color:var(--mut)}
330.fd-ssp-switch{display:flex;align-items:center;gap:12px;padding-bottom:30px}
331.fd-ssp-track{position:relative;display:block;width:44px;height:26px;border-radius:9999px;background:#D6D6D0;transition:background-color var(--fd-dur-2)}
332.fd-ssp-track span{position:absolute;top:3px;left:3px;width:20px;height:20px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.2);transition:transform var(--fd-dur-2) var(--fd-spring)}
333.fd-ssp-switch[aria-checked="true"] .fd-ssp-track{background:var(--fd-ssp-ink)}
334.fd-ssp-switch[aria-checked="true"] .fd-ssp-track span{transform:translateX(18px)}
335.fd-ssp-sw-t{display:flex;flex-direction:column;font:600 14px/20px var(--sans)}
336.fd-ssp-sw-t small{font-weight:400;font-size:12px;line-height:16px;color:var(--mut)}
337.fd-ssp-switch[aria-checked="true"] small{color:var(--fd-ssp-signal);font-weight:600}
338
339.fd-ssp-scale{margin-top:36px;padding-top:44px}
340.fd-ssp-bar{position:relative;height:96px}
341.fd-ssp-seg{position:absolute;top:0;height:12px;background:var(--soft);box-shadow:inset 1px 0 0 var(--fd-ssp-bg);overflow:visible;transition:background-color var(--fd-dur-2)}
342.fd-ssp-seg:first-of-type{border-radius:6px 0 0 6px}
343.fd-ssp-seg[data-sales]{border-radius:0 6px 6px 0;background:repeating-linear-gradient(-45deg,var(--soft) 0 4px,var(--fd-ssp-bg) 4px 8px)}
344.fd-ssp-seg[data-past]{background:#CFCFC9}
345.fd-ssp-seg[data-on]{background:var(--fd-ssp-ink)}
346.fd-ssp-seg[data-on][data-sales]{background:var(--fd-ssp-signal)}
347.fd-ssp-sweep{position:absolute;inset:0;background:var(--fd-ssp-signal);transform-origin:left;animation:fd-ssp-sweep var(--fd-dur-4) var(--fd-ease-out) forwards}
348@keyframes fd-ssp-sweep{0%{transform:scaleX(0);opacity:1}60%{transform:scaleX(1);opacity:1}100%{transform:scaleX(1);opacity:0}}
349.fd-ssp-seg b,.fd-ssp-seg em{position:absolute;left:8px;white-space:nowrap;font-style:normal}
350.fd-ssp-seg b{top:24px;font:700 17px/24px var(--sans);letter-spacing:-.02em;color:var(--mut);font-variant-numeric:tabular-nums;transition:color var(--fd-dur-2)}
351.fd-ssp-seg em{top:48px;font:400 12px/16px var(--sans);color:var(--mut)}
352.fd-ssp-seg[data-on] b{color:var(--fd-ssp-ink)}
353.fd-ssp-seg[data-on][data-sales] b{color:var(--fd-ssp-signal)}
354.fd-ssp-seg::before{content:"";position:absolute;left:0;top:12px;height:56px;width:1px;background:var(--line)}
355.fd-ssp-marker{position:absolute;top:-38px;left:calc(var(--x) * 100%);transform:translateX(-50%);transition:left var(--fd-dur-3) var(--fd-spring);z-index:2}
356.fd-ssp-marker span{display:block;padding:3px 8px;border-radius:6px;background:var(--fd-ssp-signal);color:#fff;font:700 12px/16px var(--sans);font-variant-numeric:tabular-nums;white-space:nowrap}
357.fd-ssp-marker::after{content:"";position:absolute;left:50%;top:100%;width:2px;height:24px;margin-left:-1px;background:var(--fd-ssp-signal)}
358.fd-ssp-marker[data-over]{transform:translateX(-100%)}
359
360.fd-ssp-sum{position:relative;padding:28px;border-radius:24px;background:#fff;box-shadow:0 0 0 1px var(--line),0 20px 40px -28px rgba(0,0,0,.25)}
361.fd-ssp-sum-l{font:500 13px/18px var(--sans);color:var(--mut)}
362.fd-ssp-per{margin-top:6px;font:700 clamp(3.5rem,5vw,4.5rem)/1 var(--sans);letter-spacing:-.05em;font-variant-numeric:tabular-nums;animation:fd-ssp-pop var(--fd-dur-3) var(--fd-spring)}
363@keyframes fd-ssp-pop{from{transform:translateY(8px);opacity:.2}to{transform:none;opacity:1}}
364.fd-ssp-math{margin-top:24px;border-top:1px solid var(--line)}
365.fd-ssp-math div{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--line);font-variant-numeric:tabular-nums}
366.fd-ssp-math dt{color:var(--mut)}
367.fd-ssp-math dd{font-weight:600}
368.fd-ssp-math .fd-ssp-tot{border-bottom:0;padding-top:14px}
369.fd-ssp-tot dt{color:var(--fd-ssp-ink);font-weight:600}
370.fd-ssp-tot dd{font:700 1.5rem/28px var(--sans);letter-spacing:-.03em}
371.fd-ssp-hint{margin-top:8px;padding:10px 12px;border-radius:10px;background:color-mix(in srgb,var(--fd-ssp-signal) 9%,#fff);color:#A63200;font:500 13px/18px var(--sans)}
372.fd-ssp-cta{display:flex;align-items:center;justify-content:center;margin-top:20px;height:52px;border-radius:14px;background:var(--fd-ssp-ink);color:#fff;font:600 15px/52px var(--sans);transition:background-color var(--fd-dur-1),transform var(--fd-dur-2) var(--fd-spring)}
373.fd-ssp-cta:hover{background:#2A2A27}
374.fd-ssp-cta:active{transform:scale(.96);transition-duration:var(--fd-dur-1)}
375.fd-ssp-sum[data-sales] .fd-ssp-cta{background:var(--fd-ssp-signal)}
376.fd-ssp-fine{margin-top:12px;font:400 12px/17px var(--sans);color:var(--mut)}
377.fd-ssp-sales{animation:fd-ssp-pop var(--fd-dur-3) var(--fd-ease-out)}
378.fd-ssp-sales-t{margin-top:6px;font:700 2rem/36px var(--sans);letter-spacing:-.035em}
379.fd-ssp-sales-d{margin-top:10px;color:var(--mut)}
380.fd-ssp-person{display:flex;align-items:center;gap:12px;margin-top:20px;padding:12px;border-radius:14px;background:var(--soft)}
381.fd-ssp-person img{width:48px;height:48px;border-radius:50%;object-fit:cover}
382.fd-ssp-person span{display:flex;flex-direction:column;font:400 13px/18px var(--sans);color:var(--mut)}
383.fd-ssp-person b{font-weight:600;color:var(--fd-ssp-ink);font-size:14px}
384.fd-ssp-person small{margin-top:2px;font-size:12px;color:#1F7A3A}
385.fd-ssp-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
386@media (max-width:960px){.fd-ssp-wrap{grid-template-columns:1fr}}
387@media (max-width:560px){
388 .fd-ssp-seg b{font-size:13px;left:4px}
389 .fd-ssp-seg em{font-size:10px;left:4px}
390 .fd-ssp-seg[data-sales] b,.fd-ssp-seg[data-sales] em{left:auto;right:0}
391 .fd-ssp-switch{padding-bottom:0}
392 .fd-ssp-sum{padding:22px}
393}
394@media (prefers-reduced-motion:reduce){.fd-ssp *{transition-duration:1ms!important;animation:none!important}}
395@media (prefers-contrast:more){.fd-ssp{--line:#111110}}
396`;
397
398export function Demo() {
399 return (
400 <div className="fd-sspd">
401 <style>{demoCss}</style>
402 <header className="fd-sspd-nav">
403 <a href="#home" className="fd-sspd-logo">
404 <svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true">
405 <rect x="2" y="12" width="16" height="6" fill="#111110" />
406 <rect x="5" y="6" width="10" height="6" fill="#111110" />
407 <rect x="8" y="1" width="4" height="5" fill="#FF4D00" />
408 </svg>
409 Plinth
410 </a>
411 <nav aria-label="Main">
412 <a href="#product">Product</a>
413 <a href="#pricing" aria-current="page">Pricing</a>
414 <a href="#security">Security</a>
415 </nav>
416 <a href="#login" className="fd-sspd-in">Log in</a>
417 </header>
418 <SeatStepperPricing />
419 </div>
420 );
421}
422
423const demoCss = `
424:where(.fd-sspd,.fd-sspd *){box-sizing:border-box;margin:0;padding:0;font:inherit;color:inherit;text-decoration:none}
425.fd-sspd{min-height:100vh;background:#FAFAF8;color:#111110;font:400 14px/20px ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif}
426.fd-sspd-nav{display:flex;align-items:center;justify-content:space-between;gap:20px;height:60px;padding:0 clamp(16px,4vw,56px);border-bottom:1px solid #E3E3DE}
427.fd-sspd-logo{display:flex;align-items:center;gap:8px;font-weight:700;font-size:17px;letter-spacing:-.03em}
428.fd-sspd-logo svg{display:block}
429.fd-sspd-nav nav{display:flex;gap:24px;color:#6F6F6A;font-weight:500}
430.fd-sspd-nav nav a[aria-current]{color:#111110}
431.fd-sspd-in{font-weight:600}
432@media (max-width:640px){.fd-sspd-nav nav{display:none}}
433`;

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 Seat Stepper Pricing

Run npx shadcn@latest add https://freedesignmd.com/r/seat-stepper-pricing.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.

Per-seat pricing stepper with hold-to-accelerate, a volume-discount scale, next price break flag and a sales handoff past 250. 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. color-mix() for the hint tint (Chrome 111+, Safari 16.2+, Firefox 113+).

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.