Plan card
A subscription plan with its name, its price per interval, its features and a link to start, with a featured plan, and a row of plans with a monthly or yearly price.
From Figma 05 Commerce, Plan Card (7275:14284; tiers Basic 7275:14285, Grow 7275:14303,
Scale 7275:14325). Uses Card, Badge,
Button, Text,
NumberValue and, in the row,
SegmentedControl and Stack, with the Check and X icons from
@nizivo/icons.
Examples
One plan
Figma 05's Plan Card (7275:14284), the Basic tier: the name, the price per month, a line about it, the features and a link to start. A server composition: it ships no JavaScript.
Basic
$29/ month
Everything to open a store
- Online store and checkout
- Up to 2 staff accounts
- Discount codes
- Not included: Shipping labels
import type { ComponentPropsWithRef, JSX } from 'react';
import { Check, X } from '@nizivo/icons';
import { Badge } from '@nizivo/react/badge';
import { Button } from '@nizivo/react/button';
import { Card } from '@nizivo/react/card';
import { NumberValue } from '@nizivo/react/number-value';
import { Text } from '@nizivo/react/text';
/** Whole amounts in the store's currency: Intl formats them, never string concatenation. */
const USD = { style: 'currency', currency: 'USD', maximumFractionDigits: 0 } as const;
type Interval = 'month' | 'year';
interface Feature {
label: string;
included: boolean;
}
interface Plan {
id: string;
name: string;
tagline: string;
/** The price for each interval. */
price: Record<Interval, number>;
features: Feature[];
/** The recommended plan: the one inverse card, with the accent CTA. */
featured: boolean;
}
const BASIC: Plan = {
id: 'basic',
name: 'Basic',
tagline: 'Everything to open a store',
price: { month: 29, year: 290 },
features: [
{ label: 'Online store and checkout', included: true },
{ label: 'Up to 2 staff accounts', included: true },
{ label: 'Discount codes', included: true },
{ label: 'Shipping labels', included: false },
],
featured: false,
};
/** Stands in for your router's link component, such as Next's `Link`. */
function Link({ children, ...props }: ComponentPropsWithRef<'a'>): JSX.Element {
return <a {...props}>{children}</a>;
}
/** "{price} / {interval}": the amount from NumberValue, the interval a separate word. */
function Price({ value, interval }: { value: number; interval: Interval }): JSX.Element {
return (
<p className={`nzp-plan-card-price nzp-plan-card-price-${interval}`}>
<Text render={<span />} variant="numeric-display">
<NumberValue value={value} format={USD} />
</Text>
<Text render={<span />} tone="secondary">
{interval === 'month' ? '/ month' : '/ year'}
</Text>
</p>
);
}
/** A feature line: a check or a cross, and the words that say which for screen readers. */
function FeatureItem({ feature }: { feature: Feature }): JSX.Element {
return (
<li
className={
feature.included
? 'nzp-plan-card-feature'
: 'nzp-plan-card-feature nzp-plan-card-feature-excluded'
}
>
{feature.included ? <Check size={16} /> : <X size={16} />}
<span>
{feature.included ? null : <span className="nz-visually-hidden">Not included: </span>}
{feature.label}
</span>
</li>
);
}
/** One plan. Both prices are rendered; the row's interval control shows one (see the stylesheet). */
function PlanCard({ plan, intervals }: { plan: Plan; intervals: Interval[] }): JSX.Element {
const titleId = `plan-card-${plan.id}-${intervals.length}`;
return (
<Card.Root
className="nzp-plan-card"
tone={plan.featured ? 'inverse' : 'surface'}
elevation={plan.featured ? 1 : 0}
render={<article aria-labelledby={titleId} />}
>
<div className="nzp-plan-card-header">
<Card.Title id={titleId}>{plan.name}</Card.Title>
{plan.featured ? (
<Badge tone="brand" dot={false}>
Popular
</Badge>
) : null}
</div>
{intervals.map((interval) => (
<Price key={interval} value={plan.price[interval]} interval={interval} />
))}
<Text tone="secondary">{plan.tagline}</Text>
<ul className="nzp-plan-card-features">
{plan.features.map((feature) => (
<FeatureItem key={feature.label} feature={feature} />
))}
</ul>
<Button
className="nzp-plan-card-action"
variant={plan.featured ? 'accent' : 'secondary'}
fullWidth
render={<Link href={`#start-${plan.id}`} />}
>
{`Start with ${plan.name}`}
</Button>
</Card.Root>
);
}
export function PlanCardExample(): JSX.Element {
return <PlanCard plan={BASIC} intervals={['month']} />;
}
A row of plans
A row of plans with a monthly or yearly price. The interval is a SegmentedControl, native radios, and the row's stylesheet shows the prices of the checked one with :has(), so it works before any JavaScript runs. An app that loads prices per interval reads the control's value instead (onValueChange in a client component, or the form on the server) and renders one price.
Basic
$29/ month
$290/ year
Everything to open a store
- Online store and checkout
- Up to 2 staff accounts
- Discount codes
- Not included: Shipping labels
Grow
Popular$79/ month
$790/ year
For brands selling every day
- Everything in Basic
- Up to 5 staff accounts
- Shipping labels
- Reports
Scale
$299/ month
$2,990/ year
For teams with several stores
- Everything in Grow
- Up to 15 staff accounts
- Custom reports
- Priority support
import type { ComponentPropsWithRef, JSX } from 'react';
import { Check, X } from '@nizivo/icons';
import { Badge } from '@nizivo/react/badge';
import { Button } from '@nizivo/react/button';
import { Card } from '@nizivo/react/card';
import { NumberValue } from '@nizivo/react/number-value';
import { SegmentedControl } from '@nizivo/react/segmented-control';
import { Stack } from '@nizivo/react/stack';
import { Text } from '@nizivo/react/text';
/** Whole amounts in the store's currency: Intl formats them, never string concatenation. */
const USD = { style: 'currency', currency: 'USD', maximumFractionDigits: 0 } as const;
type Interval = 'month' | 'year';
interface Feature {
label: string;
included: boolean;
}
interface Plan {
id: string;
name: string;
tagline: string;
/** The price for each interval. */
price: Record<Interval, number>;
features: Feature[];
/** The recommended plan: the one inverse card, with the accent CTA. */
featured: boolean;
}
const BASIC: Plan = {
id: 'basic',
name: 'Basic',
tagline: 'Everything to open a store',
price: { month: 29, year: 290 },
features: [
{ label: 'Online store and checkout', included: true },
{ label: 'Up to 2 staff accounts', included: true },
{ label: 'Discount codes', included: true },
{ label: 'Shipping labels', included: false },
],
featured: false,
};
const GROW: Plan = {
id: 'grow',
name: 'Grow',
tagline: 'For brands selling every day',
price: { month: 79, year: 790 },
features: [
{ label: 'Everything in Basic', included: true },
{ label: 'Up to 5 staff accounts', included: true },
{ label: 'Shipping labels', included: true },
{ label: 'Reports', included: true },
],
featured: true,
};
const SCALE: Plan = {
id: 'scale',
name: 'Scale',
tagline: 'For teams with several stores',
price: { month: 299, year: 2990 },
features: [
{ label: 'Everything in Grow', included: true },
{ label: 'Up to 15 staff accounts', included: true },
{ label: 'Custom reports', included: true },
{ label: 'Priority support', included: true },
],
featured: false,
};
const PLANS: Plan[] = [BASIC, GROW, SCALE];
/** Stands in for your router's link component, such as Next's `Link`. */
function Link({ children, ...props }: ComponentPropsWithRef<'a'>): JSX.Element {
return <a {...props}>{children}</a>;
}
/** "{price} / {interval}": the amount from NumberValue, the interval a separate word. */
function Price({ value, interval }: { value: number; interval: Interval }): JSX.Element {
return (
<p className={`nzp-plan-card-price nzp-plan-card-price-${interval}`}>
<Text render={<span />} variant="numeric-display">
<NumberValue value={value} format={USD} />
</Text>
<Text render={<span />} tone="secondary">
{interval === 'month' ? '/ month' : '/ year'}
</Text>
</p>
);
}
/** A feature line: a check or a cross, and the words that say which for screen readers. */
function FeatureItem({ feature }: { feature: Feature }): JSX.Element {
return (
<li
className={
feature.included
? 'nzp-plan-card-feature'
: 'nzp-plan-card-feature nzp-plan-card-feature-excluded'
}
>
{feature.included ? <Check size={16} /> : <X size={16} />}
<span>
{feature.included ? null : <span className="nz-visually-hidden">Not included: </span>}
{feature.label}
</span>
</li>
);
}
/** One plan. Both prices are rendered; the row's interval control shows one (see the stylesheet). */
function PlanCard({ plan, intervals }: { plan: Plan; intervals: Interval[] }): JSX.Element {
const titleId = `plan-card-${plan.id}-${intervals.length}`;
return (
<Card.Root
className="nzp-plan-card"
tone={plan.featured ? 'inverse' : 'surface'}
elevation={plan.featured ? 1 : 0}
render={<article aria-labelledby={titleId} />}
>
<div className="nzp-plan-card-header">
<Card.Title id={titleId}>{plan.name}</Card.Title>
{plan.featured ? (
<Badge tone="brand" dot={false}>
Popular
</Badge>
) : null}
</div>
{intervals.map((interval) => (
<Price key={interval} value={plan.price[interval]} interval={interval} />
))}
<Text tone="secondary">{plan.tagline}</Text>
<ul className="nzp-plan-card-features">
{plan.features.map((feature) => (
<FeatureItem key={feature.label} feature={feature} />
))}
</ul>
<Button
className="nzp-plan-card-action"
variant={plan.featured ? 'accent' : 'secondary'}
fullWidth
render={<Link href={`#start-${plan.id}`} />}
>
{`Start with ${plan.name}`}
</Button>
</Card.Root>
);
}
export function PlanCardRowExample(): JSX.Element {
return (
<Stack gap="5" className="nzp-plan-card-plans">
<SegmentedControl.Root aria-label="Billing" name="plan-interval" defaultValue="month">
<SegmentedControl.Item value="month">Monthly</SegmentedControl.Item>
<SegmentedControl.Item value="year">Yearly</SegmentedControl.Item>
</SegmentedControl.Root>
<ul className="nzp-plan-card-row" aria-label="Plans">
{PLANS.map((plan) => (
<li key={plan.id}>
<PlanCard plan={plan} intervals={['month', 'year']} />
</li>
))}
</ul>
</Stack>
);
}
Stylesheet
@layer nz.tokens, nz.themes, nz.reset, nz.base, nz.components, nz.patterns, nz.utilities;
@layer nz.patterns {
.nzp-plan-card {
block-size: 100%;
border-radius: var(--nz-radius-2xl);
max-inline-size: 22rem;
}
.nzp-plan-card-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--nz-space-2);
}
/* "{price} / {interval}" on one baseline */
.nzp-plan-card-price {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--nz-space-1);
margin: 0;
}
.nzp-plan-card-features {
display: flex;
flex-direction: column;
gap: var(--nz-space-2);
margin: 0;
padding: 0;
list-style: none;
}
.nzp-plan-card-feature {
display: flex;
align-items: baseline;
gap: var(--nz-space-2);
font: var(--nz-font-body-m);
& > svg {
flex: none;
align-self: center;
color: var(--nz-icon-brand);
}
}
.nzp-plan-card-feature-excluded {
color: var(--nz-text-secondary);
& > svg {
color: var(--nz-icon-secondary);
}
}
/* the action sits at the card's foot, so a row of cards lines its buttons up */
.nzp-plan-card-action {
margin-block-start: auto;
}
/* the interval control keeps its own width; the row of cards takes the whole width */
.nzp-plan-card-plans {
align-items: start;
}
.nzp-plan-card-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
gap: var(--nz-space-4);
align-self: stretch;
margin: 0;
padding: 0;
list-style: none;
& .nzp-plan-card {
max-inline-size: none;
}
}
/* the checked interval's prices show; the other's are hidden from everyone */
.nzp-plan-card-plans:not(:has(input[value='year']:checked)) .nzp-plan-card-price-year,
.nzp-plan-card-plans:has(input[value='year']:checked) .nzp-plan-card-price-month {
display: none;
}
}
Notes
- Client JavaScript. One plan is a server composition. The row adds a SegmentedControl, a client component; the prices switch through CSS on its native radios, so they work before it hydrates.
- Featured is structure, Tier is data. Figma's Tier axis (Basic, Grow, Scale) is content; the
one structural difference is the featured plan, the only inverse card, with the accent CTA and a
"Popular" Badge. In the dark theme
bg/inverseis light, so the featured plan becomes the only light card; Card's inverse tone keeps every colour inside it in the inverse scheme. Figma'sElevation/3 Popoveris for floating layers; the featured card takes elevation 1. - Price.
{price} / monthis aNumberValueand a separate word, never one concatenated string, so a translation can reorder them and the amount keeps its tabular figures. - Features are a
<ul>. The icons are decorative; a feature the plan lacks says "Not included" in visually hidden text, as the cross alone would be colour and shape only. - The CTA is a link drawn as a Button (
render={<a />}): starting a plan goes to a page. Its name is "Start with Basic", unique per card. - Contrast. Figma's tagline and "/ month" on the inverse card use
text/tertiary, 4.03:1; here they aretext/secondary, resolved in the inverse scheme.
Shipment tracker
A shipment's steps as an ordered list, the current one marked with aria-current="step" and every step's status said in words, a column on a narrow card and a row on a wide one. A server composition; it ships no JavaScript.
Customer card
Who a customer is, what they are worth and how to reach them, an Avatar, their name and email, their orders and total spent, and their tags. A server composition; it ships no JavaScript.