Pricing with billing toggle
A two-state radio group for monthly and annual, with the saving stated in money.
Live preview
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.
Related
All sectionsThree-tier pricing
Three plans with the recommended tier raised rather than merely tinted.
starterFeatured3 variantsUsage pricing calculator
Two sliders, a live total and a visible breakdown.
advancedFeatured3 variantsSingle-plan pricing
One price presented across the full width rather than as a lone card.
starter3 variants