ProductPricing cards
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callPricing cards
Three plans with a billing switch and a seat slider. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add pricing-cardsReact
npx designme add pricing-cards --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
Pricing cards shows your plans side by side, with a Monthly and Yearly switch and a slider for how many editors the price should cover. Each paid plan works out its own total for that team size and billing period, and prices roll to their new value when the period changes. Once the team is large enough, the outline can move from the popular plan to a bigger one.
- When an agent product is priced per seat and buyers want to see the total for their team
- When yearly billing carries a discount you want to show next to the monthly price
- When a free, a paid and a talk-to-sales plan sit on one page
- When larger teams should be steered toward an enterprise plan
- The billing switch shows the yearly saving as a tag, worked out from
yearlyDiscount. - On yearly billing, paid plans show the discounted price with the monthly price struck through.
- The editors slider, 1 to 50 by default, updates totals like "$1,152 billed yearly for 5 editors".
- A
priceof 0 reads as free ("forever"), and null showspriceLabel, "Custom" by default. popularadds a Most popular badge and outline; a plan withfromSeatstakes the outline once the slider reaches that number.- Only the outlined plan's button is primary, on the accent; the others are white with a grey outline, and the primary button moves with the outline. Set an action's
variantto fix a button's look. - Each plan's button calls
onChoosewith the plan, the period and the number of editors.
- The billing switch is a
grouplabelled Billing, witharia-pressedon Monthly and Yearly. - The editors slider is a native range input labelled with
seatsLabel, so arrow keys change it.
Usage
Import it and drive it from your own state.
Import
import { PricingCards } from '@/components/designme/pricing-cards/PricingCards';Example
The demo above as a React component, 23 lines you can copy.
'use client';
// The preview from designme.dev, rebuilt from the React component: switch the billing period or drag the editors slider.
import { useRef, useState } from 'react';
import { useStory } from '../lib/useScript';
import { PricingCards, type PricingPeriod, type PricingPlan } from './PricingCards';
const PLANS: PricingPlan[] = [
{ name: 'Starter', description: 'For trying the agent', price: 0, total: 'Up to 2 editors', features: ['1 project', '50 agent runs a month', 'Figma import'], action: { label: 'Start free' } },
{ name: 'Pro', description: 'For growing design teams', price: 24, popular: true, features: ['Unlimited projects', '1,000 runs per editor', 'Shared tokens and memory', 'Priority models'], action: { label: 'Start free trial' } },
{ name: 'Enterprise', description: 'For design orgs', price: null, unit: 'billed yearly', total: 'Volume pricing from 25 editors', fromSeats: 25, features: ['SSO and audit log', 'Custom spend caps', 'Dedicated support'], action: { label: 'Talk to us' } }
];
export default function PricingCardsDemo() {
const [period, setPeriod] = useState<PricingPeriod>('yearly');
// once it has appeared it shows the switch at work: monthly, then back to yearly. Clicking, focusing or typing in it takes over
const ref = useRef<HTMLDivElement>(null);
useStory(ref, async ({ wait }) => {
await wait(1900); setPeriod('monthly');
await wait(2000); setPeriod('yearly');
});
return <div ref={ref} style={{ width: '100%' }}><PricingCards plans={PLANS} period={period} onPeriodChange={setPeriod} /></div>;
}
Props
Everything PricingCards takes.
| Prop | Type | What it does |
|---|---|---|
plansrequired | PricingPlan[] | |
period | PricingPeriod | monthly or yearly. Leave it out to let the switch change it |
defaultPeriod | PricingPeriod | |
onPeriodChange | (period: PricingPeriod) => void | |
yearlyDiscount | number | Taken off a paid plan's price when billed yearly, 0.2 for 20% |
seats | number | How many editors the totals are for. Leave it out to let the slider change it |
defaultSeats | number | |
onSeatsChange | (seats: number) => void | |
minSeats | number | The slider's range, 1 to 50 by default |
maxSeats | number | |
seatsLabel | string | The slider's label, like "Editors" |
popularLabel | string | The badge on the popular plan |
onChoose | (plan: PricingPlan, choice: { period: PricingPeriod; seats: number }) => void | Called with the plan when its button is pressed, and the period and editors it was priced for |
className | string |
Code
The full source, as the CLI installs it.
PricingCards.tsxReact · 134 lines
'use client';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useTween } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { enter, Seg } from '../lib/motion';
import '../designme.css';
import './pricing-cards.css';
export type PricingPeriod = 'monthly' | 'yearly';
export interface PricingPlan {
/** Like "Pro" */
name: string;
/** The quiet line under the name, like "For growing design teams" */
description?: string;
/** The monthly price per editor. 0 is a free plan, null is priced on request ("Custom") */
price: number | null;
/** Shown instead of a price when `price` is null. Defaults to "Custom" */
priceLabel?: string;
/** The line under the price. Defaults to "per editor, per month", or "forever" for a free plan */
unit?: string;
/** The line above the features. Paid plans work it out from the editors and the billing period; set it for free or custom plans */
total?: string;
features: ReactNode[];
/** The button at the foot of the card. Leave `variant` out and only the outlined plan's button is primary (on the accent),
with white buttons with a grey outline (`line`) on the others, so the primary one moves with the outline. Set it to fix
the look: line, soft (grey), accent or primary. ink is the old name for primary */
action: { label: string; variant?: 'line' | 'soft' | 'accent' | 'primary' | 'ink' };
/** Adds the "Most popular" badge and outlines the card */
popular?: boolean;
/** Once the editors reach this many, this card takes the outline from the popular one, like Enterprise from 25 editors */
fromSeats?: number;
}
export interface PricingCardsProps {
plans: PricingPlan[];
/** monthly or yearly. Leave it out to let the switch change it */
period?: PricingPeriod;
defaultPeriod?: PricingPeriod;
onPeriodChange?: (period: PricingPeriod) => void;
/** Taken off a paid plan's price when billed yearly, 0.2 for 20% */
yearlyDiscount?: number;
/** How many editors the totals are for. Leave it out to let the slider change it */
seats?: number;
defaultSeats?: number;
onSeatsChange?: (seats: number) => void;
/** The slider's range, 1 to 50 by default */
minSeats?: number;
maxSeats?: number;
/** The slider's label, like "Editors" */
seatsLabel?: string;
/** The badge on the popular plan */
popularLabel?: string;
/** Called with the plan when its button is pressed, and the period and editors it was priced for */
onChoose?: (plan: PricingPlan, choice: { period: PricingPeriod; seats: number }) => void;
className?: string;
}
const money = (n: number) => '$' + n.toLocaleString('en-US', { maximumFractionDigits: 0 });
/** Monthly or yearly, with a knob that slides between them like the site's switches. The yearly side carries the saving */
function PeriodSwitch({ value, onChange, saving }: { value: PricingPeriod; onChange: (p: PricingPeriod) => void; saving?: string }) {
return <Seg className="dm-pcx-per" label="Billing" value={value} onChange={onChange}
options={[{ value: 'monthly', label: 'Monthly' }, { value: 'yearly', label: <>Yearly{saving && <span className="dm-tag dm-ok">{saving}</span>}</> }]} />;
}
/** The total under the price. A new billing period crossfades it, the way the site swaps text; dragging the slider changes it in place */
function Total({ text, period }: { text: string; period: PricingPeriod }) {
const [shown, setShown] = useState(text);
const [out, setOut] = useState(false);
const last = useRef(period);
useEffect(() => {
if (period === last.current) { setShown(text); setOut(false); return; }
last.current = period; setOut(true);
const t = setTimeout(() => { setShown(text); setOut(false); }, 170);
return () => clearTimeout(t);
}, [text, period]);
return <div className={cx('dm-pcx-tot dm-num dm-sw', out && 'dm-out')}>{shown}</div>;
}
/** A price that rolls to its new value when the period changes */
function Price({ value }: { value: number }) {
const v = useTween(value, 500, 0);
return <b className="dm-num">${Number.isInteger(value) ? Math.round(v) : v.toFixed(2)}</b>;
}
/** Plans side by side, with a monthly or yearly switch and a slider for how many editors the totals cover. */
export function PricingCards({ plans, period, defaultPeriod = 'yearly', onPeriodChange, yearlyDiscount = .2, seats, defaultSeats = 5, onSeatsChange,
minSeats = 1, maxSeats = 50, seatsLabel = 'Editors', popularLabel = 'Most popular', onChoose, className }: PricingCardsProps) {
const [per, setPer] = useControllable(period, defaultPeriod, onPeriodChange);
const [n, setN] = useControllable(seats, defaultSeats, onSeatsChange);
const yearly = per === 'yearly';
// the outline sits on the popular plan, or on a bigger plan once the editors reach its threshold
const big = plans.reduce((at, p, i) => (p.fromSeats !== undefined && n >= p.fromSeats ? i : at), -1);
const hot = big >= 0 ? big : plans.findIndex(p => p.popular);
const saving = yearlyDiscount > 0 ? `−${Math.round(yearlyDiscount * 100)}%` : undefined;
return (
<div data-dm="" className={cx('dm-pcx', className)}>
<div className="dm-pcx-top">
<PeriodSwitch value={per} onChange={setPer} saving={saving} />
<label className="dm-pcx-seats">
<span className="dm-muted">{seatsLabel}</span>
<input type="range" min={minSeats} max={maxSeats} value={n} aria-label={seatsLabel} onChange={e => setN(+e.target.value)} />
<b className="dm-num">{n}</b>
</label>
</div>
<div className="dm-pcx-grid">
{plans.map((p, i) => {
const unit = p.price === null ? 0 : yearly ? p.price * (1 - yearlyDiscount) : p.price;
const total = p.total ?? (p.price ? `${money(unit * n * (yearly ? 12 : 1))} ${yearly ? 'billed yearly' : 'a month'} for ${n} editor${n > 1 ? 's' : ''}` : '');
const e = enter(i * 80);
return (
<div key={p.name} className={cx('dm-pcx-c dm-panel', i === hot && 'dm-hot', e.className)} style={e.style}>
{p.popular && <span className="dm-tag dm-b dm-bd">{popularLabel}</span>}
<span className="dm-nm">{p.name}</span>
{p.description && <span className="dm-ds">{p.description}</span>}
<div className="dm-pcx-pr">
{p.price === null ? <b className="dm-num">{p.priceLabel ?? 'Custom'}</b> : <Price value={unit} />}
<s className="dm-num">{yearly && p.price ? '$' + p.price : ''}</s>
<span>{p.unit ?? (p.price === null ? '' : p.price ? 'per editor, per month' : 'forever')}</span>
</div>
<Total text={total} period={per} />
<div className="dm-pcx-li">{p.features.map((f, k) => <span key={k}><Icon name="check" size="xs" />{f}</span>)}</div>
<button type="button" className={cx('dm-pill', 'dm-' + ((p.action.variant === 'ink' ? 'primary' : p.action.variant) ?? (i === hot ? 'primary' : 'line')))} onClick={() => onChoose?.(p, { period: per, seats: n })}>{p.action.label}</button>
</div>
);
})}
</div>
</div>
);
}
pricing-cards.cssReact · 31 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Pricing cards ---------- */
.dm-pcx{display:flex;flex-direction:column;gap:14px;width:100%}
.dm-pcx-top{display:flex;align-items:center;justify-content:space-between;gap:var(--dm-s-3);flex-wrap:wrap}
.dm-pcx-per button{font-size:12.5px;height:30px;padding:0 var(--dm-s-3);display:inline-flex;align-items:center;gap:6px}
.dm-pcx-per .dm-tag{height:18px;font-size:10.5px;padding:0 5px}
.dm-pcx-seats{display:flex;align-items:center;gap:10px;font-size:12.5px}
.dm-pcx-seats input{width:150px;accent-color:var(--dm-accent)}
.dm-pcx-seats b{width:22px;font-weight:500}
.dm-pcx-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--dm-s-3)}
@media (max-width:760px){.dm-pcx-grid{grid-template-columns:1fr}}
.dm-pcx-c{padding:18px;display:flex;flex-direction:column;gap:var(--dm-s-3);transition:transform .45s var(--dm-ease),box-shadow .3s;position:relative}
@media (hover:hover) and (pointer:fine){.dm-pcx-c:hover{box-shadow:var(--dm-shadow-pop)}}
.dm-pcx-c.dm-hot{box-shadow:0 0 0 1.5px var(--dm-accent-mark,var(--dm-accent)),var(--dm-shadow)}
@media (hover:hover) and (pointer:fine){.dm-pcx-c.dm-hot:hover{box-shadow:0 0 0 1.5px var(--dm-accent-mark,var(--dm-accent)),var(--dm-shadow-pop)}}
.dm-pcx-c .dm-bd{position:absolute;top:16px;right:16px}
.dm-pcx-c .dm-nm{font-size:14px;font-weight:500}
.dm-pcx-c .dm-ds{font-size:12.5px;color:var(--dm-muted);margin-top:-8px}
.dm-pcx-pr{display:flex;flex-wrap:wrap;align-items:baseline;gap:0 6px}
.dm-pcx-pr b{font-size:14px;font-weight:500;letter-spacing:-.02em}
.dm-pcx-pr span{flex-basis:100%;min-height:18px;font-size:12px;color:var(--dm-muted)}
.dm-pcx-pr s{font-size:13px;color:var(--dm-muted)}
.dm-pcx-tot{font-size:12px;color:var(--dm-fg2);min-height:16px}
.dm-pcx-li{display:flex;flex-direction:column;gap:7px;font-size:13px;color:var(--dm-fg2);margin:var(--dm-s-1) 0}
.dm-pcx-li span{display:flex;align-items:center;gap:var(--dm-s-2)}
.dm-pcx-li svg{color:var(--dm-accent-fg,var(--dm-accent));flex:none}
.dm-pcx-c .dm-pill{width:100%;height:38px;margin-top:auto}
/* only the recommended plan's button is the accent; the others are white with a grey outline */
.dm-pcx-c .dm-pill.dm-line{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-rail) 50%,transparent)}
@media (hover:hover) and (pointer:fine){.dm-pcx-c .dm-pill.dm-line:hover{background:var(--dm-wash);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-faint) 50%,transparent)}}
pricing-cards.htmlHTML · 10 lines
<div class="card wide" data-c="pricing-cards">
<div class="stage-in">
<div class="pcx">
<div class="pcx-top"><div class="seg2 pcx-per" role="group" aria-label="Billing"><button aria-pressed="false" data-v="m">Monthly</button><button aria-pressed="true" data-v="y">Yearly <span class="tag ok">−20%</span></button></div>
<label class="pcx-seats"><span class="muted">Editors</span><input type="range" min="1" max="50" value="5" aria-label="Editors"><b class="num pcx-sn">5</b></label></div>
<div class="pcx-grid"></div>
</div>
</div>
</div>
pricing-cards.cssHTML · 30 lines
/* ---------- Pricing cards ---------- */
.pcx{display:flex;flex-direction:column;gap:14px;width:100%}
.pcx-top{display:flex;align-items:center;justify-content:space-between;gap:var(--s-3);flex-wrap:wrap}
.pcx-per button{font-size:12.5px;height:30px;padding:0 var(--s-3);display:inline-flex;align-items:center;gap:6px}
.pcx-per .tag{height:18px;font-size:10.5px;padding:0 5px}
.pcx-seats{display:flex;align-items:center;gap:10px;font-size:12.5px}
.pcx-seats input{width:150px;accent-color:var(--accent)}
.pcx-seats b{width:22px;font-weight:500}
.pcx-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--s-3)}
@media (max-width:760px){.pcx-grid{grid-template-columns:1fr}}
.pcx-c{padding:18px;display:flex;flex-direction:column;gap:var(--s-3);transition:transform .45s var(--ease),box-shadow .3s;position:relative}
@media (hover:hover) and (pointer:fine){.pcx-c:hover{box-shadow:var(--shadow-pop)}}
.pcx-c.hot{box-shadow:0 0 0 1.5px var(--accent-mark,var(--accent)),var(--shadow)}
@media (hover:hover) and (pointer:fine){.pcx-c.hot:hover{box-shadow:0 0 0 1.5px var(--accent-mark,var(--accent)),var(--shadow-pop)}}
.pcx-c .bd{position:absolute;top:16px;right:16px}
.pcx-c .nm{font-size:14px;font-weight:500}
.pcx-c .ds{font-size:12.5px;color:var(--muted);margin-top:-8px}
.pcx-pr{display:flex;flex-wrap:wrap;align-items:baseline;gap:0 6px}
.pcx-pr b{font-size:14px;font-weight:500;letter-spacing:-.02em}
.pcx-pr span{flex-basis:100%;min-height:18px;font-size:12px;color:var(--muted)}
.pcx-pr s{font-size:13px;color:var(--muted)}
.pcx-tot{font-size:12px;color:var(--fg2);min-height:16px}
.pcx-li{display:flex;flex-direction:column;gap:7px;font-size:13px;color:var(--fg2);margin:var(--s-1) 0}
.pcx-li span{display:flex;align-items:center;gap:var(--s-2)}
.pcx-li svg{color:var(--accent-fg,var(--accent));flex:none}
.pcx-c .pill{width:100%;height:38px;margin-top:auto}
/* only the recommended plan's button is the accent; the others are white with a grey outline */
.pcx-c .pill.line{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--rail) 50%,transparent)}
@media (hover:hover) and (pointer:fine){.pcx-c .pill.line:hover{background:var(--wash);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--faint) 50%,transparent)}}
pricing-cards.jsHTML · 31 lines
DM.def('pricing-cards', (r, c) => {
const P = [
{ n:'Starter', d:'For trying the agent', m:0, f:['1 project','50 agent runs a month','Figma import'], cta:'Start free' },
{ n:'Pro', d:'For growing design teams', m:24, f:['Unlimited projects','1,000 runs per editor','Shared tokens and memory','Priority models'], cta:'Start free trial', hot:1 },
{ n:'Enterprise', d:'For design orgs', m:null, f:['SSO and audit log','Custom spend caps','Dedicated support'], cta:'Talk to us' }
];
const grid = $('.pcx-grid', r), rng = $('.pcx-seats input', r), sn = $('.pcx-sn', r);
let per = 'y', seats = 5; const shown = P.map(() => 0);
grid.innerHTML = P.map(p => `<div class="pcx-c panel${p.hot ? ' hot' : ''}">${p.hot ? '<span class="tag b bd">Most popular</span>' : ''}<span class="nm">${p.n}</span><span class="ds">${p.d}</span><div class="pcx-pr"><b class="num"></b><s class="num"></s><span></span></div><div class="pcx-tot num"></div><div class="pcx-li">${p.f.map(x => `<span>${I('check','xs')}${x}</span>`).join('')}</div><button class="pill ${p.hot ? 'primary' : 'line'}">${p.cta}</button></div>`).join('');
const cards = $$('.pcx-c', grid);
// the outlined card is the recommended one, and only its button is primary
const ctas = () => cards.forEach(el => { const b = $('.pill', el), on = el.classList.contains('hot'); b.classList.toggle('primary', on); b.classList.toggle('line', !on); });
function upd(){
P.forEach((p, i) => { const el = cards[i], b = $('.pcx-pr b', el), s = $('.pcx-pr s', el), u = $('.pcx-pr span', el), tt = $('.pcx-tot', el);
if (p.m === null){ b.textContent = 'Custom'; s.textContent = ''; u.textContent = 'billed yearly'; tt.textContent = 'Volume pricing from 25 editors'; return; }
const unit = per === 'y' ? p.m * .8 : p.m;
const go = () => tween(shown[i], unit, 500, v => { shown[i] = v; b.textContent = '$' + (Number.isInteger(unit) ? Math.round(v) : v.toFixed(2)); });
// the first time, the prices count up once the cards are in view
if (first){ b.textContent = '$0'; c.later(260, go); } else go();
s.textContent = per === 'y' && p.m ? '$' + p.m : ''; u.textContent = p.m ? 'per editor, per month' : 'forever';
swap(tt, p.m ? `$${(unit * seats * (per === 'y' ? 12 : 1)).toLocaleString('en-US', { maximumFractionDigits:0 })} ${per === 'y' ? 'billed yearly' : 'a month'} for ${seats} editor${seats > 1 ? 's' : ''}` : 'Up to 2 editors'); });
}
seg($('.pcx-per', r), v => { per = v; upd(); });
rng.oninput = () => { seats = +rng.value; sn.textContent = seats; P.forEach((p, i) => { if (!p.m) return; const tt = $('.pcx-tot', cards[i]); const unit = per === 'y' ? p.m * .8 : p.m; tt.textContent = `$${(unit * seats * (per === 'y' ? 12 : 1)).toLocaleString('en-US', { maximumFractionDigits:0 })} ${per === 'y' ? 'billed yearly' : 'a month'} for ${seats} editor${seats > 1 ? 's' : ''}`; });
cards[2].classList.toggle('hot', seats >= 25); cards[1].classList.toggle('hot', seats < 25); ctas(); };
let first = true; stagger(cards, 80); upd(); first = false;
// once in view it shows the switch at work: monthly, then back to yearly. Touching it takes over
const flip = v => $(`.pcx-per button[data-v="${v}"]`, r).click();
c.auto(1900, () => flip('m')); c.auto(3900, () => flip('y'));
});
Questions
What people ask about it.
- How do I know which plan, period and team size the person picked?
- Handle
onChoose. It receives the plan and an object withperiodandseats, the values the card was priced for when its button was pressed. - How do I show a free plan or a custom-priced plan?
- Set
priceto 0 for free, or null for priced on request. Paid plans work out their own total line; for free and custom plans settotalyourself, like "Up to 2 editors". - Can the Enterprise plan be highlighted for big teams?
- Yes. Give it
fromSeats, such as 25. When the slider reaches that number, the outline moves to that card from the popular one.
More in Product
Pricing, onboarding, setup flows and spending limits for the rest of your product.