Skip to content

Pricing with billing toggle

A two-state radio group for monthly and annual, with the saving stated in money.

Commerceintermediatepricingtoggleannualsaving

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

This exact file renders the preview above.

'use client'

import { useState } from 'react'
import { Check } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { ButtonLink } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { cn } from '@/lib/cn'

/**
 * Pricing with a billing-cadence toggle
 *
 * A two-state radio group rather than a switch, because "monthly or annual" is
 * a choice between two named things, not an on/off state. The saving is stated
 * in money as well as percent — "save 17%" is harder to act on than "$36 a
 * year".
 */
const tiers = [
  {
    name: 'Solo',
    monthly: 0,
    annual: 0,
    description: 'Personal projects and evaluation.',
    features: ['Every component and section', 'Copy-paste source', 'Light and dark schemes'],
  },
  {
    name: 'Team',
    monthly: 18,
    annual: 15,
    description: 'Product teams shipping together.',
    features: [
      'Everything in Solo',
      'All five palettes',
      'Starter products',
      'Priority issue triage',
    ],
    featured: true,
  },
  {
    name: 'Business',
    monthly: 32,
    annual: 27,
    description: 'Organisations with a systems function.',
    features: [
      'Everything in Team',
      'Private registry',
      'Accessibility review sessions',
      'Named support contact',
    ],
  },
]

export default function ToggleCadencePricing() {
  const [annual, setAnnual] = useState(true)

  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container>
        <div className="mx-auto max-w-2xl text-center">
          <h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
            Per seat, per month.
          </h2>

          <div
            role="radiogroup"
            aria-label="Billing cadence"
            className="mx-auto mt-7 inline-flex items-center gap-1 rounded-full border border-line bg-surface-sunken p-1"
          >
            <button
              type="button"
              role="radio"
              aria-checked={!annual}
              onClick={() => setAnnual(false)}
              className={cn(
                'rounded-full px-4 py-1.5 text-sm font-medium transition-colors',
                !annual ? 'bg-surface text-ink-strong shadow-xs' : 'text-ink-muted hover:text-ink',
              )}
            >
              Monthly
            </button>
            <button
              type="button"
              role="radio"
              aria-checked={annual}
              onClick={() => setAnnual(true)}
              className={cn(
                'flex items-center gap-2 rounded-full px-4 py-1.5 text-sm font-medium transition-colors',
                annual ? 'bg-surface text-ink-strong shadow-xs' : 'text-ink-muted hover:text-ink',
              )}
            >
              Annual
              <Badge size="sm" tone="success">
                Save $36 / seat
              </Badge>
            </button>
          </div>
        </div>

        <ul className="mt-12 grid gap-4 lg:grid-cols-3">
          {tiers.map((tier) => {
            const price = annual ? tier.annual : tier.monthly
            return (
              <li key={tier.name}>
                <div
                  className={cn(
                    'flex h-full flex-col rounded-xl border bg-surface p-6',
                    tier.featured ? 'border-accent shadow-md' : 'border-line',
                  )}
                >
                  <h3 className="display-type text-lg font-semibold text-ink-strong">
                    {tier.name}
                  </h3>
                  <p className="mt-1.5 text-sm text-ink-muted">{tier.description}</p>
                  <p className="mt-5 flex items-baseline gap-1">
                    <span className="display-type text-3xl font-semibold text-ink-strong tabular-nums">
                      ${price}
                    </span>
                    <span className="text-sm text-ink-muted">/ seat / month</span>
                  </p>
                  <p aria-live="polite" className="mt-1 text-xs text-ink-subtle">
                    {annual ? 'Billed annually' : 'Billed monthly'}
                  </p>
                  <ul className="mt-5 flex flex-1 flex-col gap-2">
                    {tier.features.map((feature) => (
                      <li key={feature} className="flex items-start gap-2 text-sm text-ink">
                        <Check className="mt-0.5 size-4 shrink-0 text-success" aria-hidden="true" />
                        {feature}
                      </li>
                    ))}
                  </ul>
                  <ButtonLink
                    href="/forms/register"
                    variant={tier.featured ? 'primary' : 'outline'}
                    block
                    className="mt-7"
                  >
                    Choose {tier.name}
                  </ButtonLink>
                </div>
              </li>
            )
          })}
        </ul>
      </Container>
    </section>
  )
}

components/blocks/sections/pricing/toggle-cadence.tsx

'use client'

import { useState } from 'react'
import { Check } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { ButtonLink } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { cn } from '@/lib/cn'

/**
 * Pricing with a billing-cadence toggle
 *
 * A two-state radio group rather than a switch, because "monthly or annual" is
 * a choice between two named things, not an on/off state. The saving is stated
 * in money as well as percent — "save 17%" is harder to act on than "$36 a
 * year".
 */
const tiers = [
  {
    name: 'Solo',
    monthly: 0,
    annual: 0,
    description: 'Personal projects and evaluation.',
    features: ['Every component and section', 'Copy-paste source', 'Light and dark schemes'],
  },
  {
    name: 'Team',
    monthly: 18,
    annual: 15,
    description: 'Product teams shipping together.',
    features: [
      'Everything in Solo',
      'All five palettes',
      'Starter products',
      'Priority issue triage',
    ],
    featured: true,
  },
  {
    name: 'Business',
    monthly: 32,
    annual: 27,
    description: 'Organisations with a systems function.',
    features: [
      'Everything in Team',
      'Private registry',
      'Accessibility review sessions',
      'Named support contact',
    ],
  },
]

export default function ToggleCadencePricing() {
  const [annual, setAnnual] = useState(true)

  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container>
        <div className="mx-auto max-w-2xl text-center">
          <h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
            Per seat, per month.
          </h2>

          <div
            role="radiogroup"
            aria-label="Billing cadence"
            className="mx-auto mt-7 inline-flex items-center gap-1 rounded-full border border-line bg-surface-sunken p-1"
          >
            <button
              type="button"
              role="radio"
              aria-checked={!annual}
              onClick={() => setAnnual(false)}
              className={cn(
                'rounded-full px-4 py-1.5 text-sm font-medium transition-colors',
                !annual ? 'bg-surface text-ink-strong shadow-xs' : 'text-ink-muted hover:text-ink',
              )}
            >
              Monthly
            </button>
            <button
              type="button"
              role="radio"
              aria-checked={annual}
              onClick={() => setAnnual(true)}
              className={cn(
                'flex items-center gap-2 rounded-full px-4 py-1.5 text-sm font-medium transition-colors',
                annual ? 'bg-surface text-ink-strong shadow-xs' : 'text-ink-muted hover:text-ink',
              )}
            >
              Annual
              <Badge size="sm" tone="success">
                Save $36 / seat
              </Badge>
            </button>
          </div>
        </div>

        <ul className="mt-12 grid gap-4 lg:grid-cols-3">
          {tiers.map((tier) => {
            const price = annual ? tier.annual : tier.monthly
            return (
              <li key={tier.name}>
                <div
                  className={cn(
                    'flex h-full flex-col rounded-xl border bg-surface p-6',
                    tier.featured ? 'border-accent shadow-md' : 'border-line',
                  )}
                >
                  <h3 className="display-type text-lg font-semibold text-ink-strong">
                    {tier.name}
                  </h3>
                  <p className="mt-1.5 text-sm text-ink-muted">{tier.description}</p>
                  <p className="mt-5 flex items-baseline gap-1">
                    <span className="display-type text-3xl font-semibold text-ink-strong tabular-nums">
                      ${price}
                    </span>
                    <span className="text-sm text-ink-muted">/ seat / month</span>
                  </p>
                  <p aria-live="polite" className="mt-1 text-xs text-ink-subtle">
                    {annual ? 'Billed annually' : 'Billed monthly'}
                  </p>
                  <ul className="mt-5 flex flex-1 flex-col gap-2">
                    {tier.features.map((feature) => (
                      <li key={feature} className="flex items-start gap-2 text-sm text-ink">
                        <Check className="mt-0.5 size-4 shrink-0 text-success" aria-hidden="true" />
                        {feature}
                      </li>
                    ))}
                  </ul>
                  <ButtonLink
                    href="/forms/register"
                    variant={tier.featured ? 'primary' : 'outline'}
                    block
                    className="mt-7"
                  >
                    Choose {tier.name}
                  </ButtonLink>
                </div>
              </li>
            )
          })}
        </ul>
      </Container>
    </section>
  )
}

components/ui/badge.tsx

import type { HTMLAttributes, ReactNode } from 'react'
import { variants } from '@/lib/variants'

/**
 * Badge
 *
 * A compact, non-interactive label. Tones map to the status token trios, and
 * because a badge is often the only signal in a dense table, the `dot` option
 * exists to add a second, colour-independent cue alongside the text.
 */
const badgeVariants = variants('inline-flex items-center gap-1.5 whitespace-nowrap font-medium', {
  variants: {
    tone: {
      neutral: 'bg-surface-sunken text-ink-muted border-line',
      accent: 'bg-accent-soft text-accent-soft-ink border-accent-line',
      success: 'bg-success-soft text-success border-success-line',
      warning: 'bg-warning-soft text-warning border-warning-line',
      danger: 'bg-danger-soft text-danger border-danger-line',
      info: 'bg-info-soft text-info border-info-line',
      inverse: 'bg-surface-inverse text-ink-inverse border-transparent',
    },
    appearance: {
      soft: 'border',
      outline: 'border bg-transparent',
      solid: 'border border-transparent',
    },
    size: {
      sm: 'h-4.5 rounded-sm px-1.5 text-2xs',
      md: 'h-5.5 rounded-sm px-2 text-xs',
    },
  },
  defaultVariants: { tone: 'neutral', appearance: 'soft', size: 'md' },
  compound: [
    { appearance: 'solid', tone: 'accent', class: 'bg-accent text-accent-ink' },
    { appearance: 'solid', tone: 'success', class: 'bg-success text-white' },
    { appearance: 'solid', tone: 'warning', class: 'bg-warning text-white' },
    { appearance: 'solid', tone: 'danger', class: 'bg-danger text-white' },
    { appearance: 'solid', tone: 'info', class: 'bg-info text-white' },
    { appearance: 'solid', tone: 'neutral', class: 'bg-ink text-ink-inverse' },
    { appearance: 'outline', tone: 'neutral', class: 'text-ink-muted' },
  ],
})

export type BadgeTone = 'neutral' | 'accent' | 'success' | 'warning' | 'danger' | 'info' | 'inverse'

export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
  tone?: BadgeTone
  appearance?: 'soft' | 'outline' | 'solid'
  size?: 'sm' | 'md'
  /** Adds a leading dot so the badge does not rely on hue alone. */
  dot?: boolean
  icon?: ReactNode
}

export function Badge({
  tone = 'neutral',
  appearance = 'soft',
  size = 'md',
  dot = false,
  icon,
  className,
  children,
  ...props
}: BadgeProps) {
  return (
    <span className={badgeVariants({ tone, appearance, size, className })} {...props}>
      {dot ? (
        <span className="size-1.5 shrink-0 rounded-full bg-current" aria-hidden="true" />
      ) : null}
      {icon}
      {children}
    </span>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

A radio group rather than a switch, because monthly or annual is a choice between two named things, not an on or off state. The saving is stated in money as well as percent.

  • Announce the cadence change politely so the price update is not silent.
  • Default to annual only if that is genuinely the better deal for most customers.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Cadence radiogroup
  • Live price update
  • Saving badge

Accessibility

Radio group
Both options are radios with aria-checked, so the current cadence is announced.
Live price
The billing line is a polite live region.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.