Skip to content
Star on GitHub
ProductPricing cards
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Pricing 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.

$npx designme add pricing-cards
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add pricing-cards

React

$npx designme add pricing-cards --react

The 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.

02

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
03

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>;
}
04

Props

Everything PricingCards takes.

PropTypeWhat it does
plansrequiredPricingPlan[]
periodPricingPeriodmonthly or yearly. Leave it out to let the switch change it
defaultPeriodPricingPeriod
onPeriodChange(period: PricingPeriod) => void
yearlyDiscountnumberTaken off a paid plan's price when billed yearly, 0.2 for 20%
seatsnumberHow many editors the totals are for. Leave it out to let the slider change it
defaultSeatsnumber
onSeatsChange(seats: number) => void
minSeatsnumberThe slider's range, 1 to 50 by default
maxSeatsnumber
seatsLabelstringThe slider's label, like "Editors"
popularLabelstringThe badge on the popular plan
onChoose(plan: PricingPlan, choice: { period: PricingPeriod; seats: number }) => voidCalled with the plan when its button is pressed, and the period and editors it was priced for
classNamestring
05

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'));
});
06

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 with period and seats, 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 price to 0 for free, or null for priced on request. Paid plans work out their own total line; for free and custom plans set total yourself, 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.
07

More in Product

Pricing, onboarding, setup flows and spending limits for the rest of your product.

Onboarding checklistFirst steps with a progress ring, and the agent can do some for you.
StepperA multi-step setup with a progress track that fills as you go.
Set limitA monthly agent budget with a draggable cap and a forecast.
Currency converterShow plan prices in local currency, with a swap and live rate.