Skip to content
Nizivo UI

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
Start with Basic
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
    Start with Basic
  • Grow

    Popular

    $79/ month

    $790/ year

    For brands selling every day

    • Everything in Basic
    • Up to 5 staff accounts
    • Shipping labels
    • Reports
    Start with Grow
  • Scale

    $299/ month

    $2,990/ year

    For teams with several stores

    • Everything in Grow
    • Up to 15 staff accounts
    • Custom reports
    • Priority support
    Start with Scale
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

plan-card.css
@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/inverse is light, so the featured plan becomes the only light card; Card's inverse tone keeps every colour inside it in the inverse scheme. Figma's Elevation/3 Popover is for floating layers; the featured card takes elevation 1.
  • Price. {price} / month is a NumberValue and 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 are text/secondary, resolved in the inverse scheme.

On this page