Pricing Plans
A considered pricing section with monthly and annual billing, honest savings, and a clear next step for every plan.
folio®EXAMPLE WORKSPACE
Make room for more.
Starter
A little structure for your next big idea.
$12 / month
Billed monthly- 3 active projects
- Your own workspace
- Unlimited notes
Studio
A little more roomMore space for the work you do together.
$29 / month
Billed monthly- Unlimited projects
- 5 team members
- Shared project timelines
Scale
A shared home for a growing company.
$59 / month
Billed monthly- Unlimited projects
- 20 team members
- Workspace permissions
Compare plans and see what’s included.
'use client';
import { useId, useState } from 'react';
import type { MouseEvent } from 'react';
import './pricing-plans.css';
export type BillingPeriod = 'monthly' | 'yearly';
export interface PricingPlan {
id: string;
name: string;
description: string;
monthly: number;
yearly: number;
features: string[];
href: string;
ctaLabel?: string;
featured?: boolean;
}
export interface PricingPlansProps {
plans: PricingPlan[];
title?: string;
currency?: string;
locale?: string;
defaultBilling?: BillingPeriod;
onBillingChange?: (period: BillingPeriod) => void;
onSelect?: (plan: PricingPlan, period: BillingPeriod, event: MouseEvent<HTMLAnchorElement>) => void;
className?: string;
}
/** Clear, annual-total pricing with ordinary links for the next step. */
export function PricingPlans({
plans,
title = 'A plan for your next chapter',
currency = 'USD',
locale = 'en-US',
defaultBilling = 'monthly',
onBillingChange,
onSelect,
className = '',
}: PricingPlansProps) {
const headingId = useId();
const [billing, setBilling] = useState<BillingPeriod>(defaultBilling);
const format = new Intl.NumberFormat(locale, {
style: 'currency', currency, minimumFractionDigits: 0,
});
const precision = 10 ** (format.resolvedOptions().maximumFractionDigits ?? 2);
function changeBilling(next: BillingPeriod) {
if (next === billing) return;
setBilling(next);
onBillingChange?.(next);
}
if (!plans.length) return null;
return (
<section className={`ac-pricing-plans ${className}`.trim()} aria-labelledby={headingId}>
<header className="ac-pricing-header">
<div>
<span className="ac-pricing-eyebrow">ROOM TO GROW</span>
<h2 id={headingId}>{title}</h2>
</div>
<div className="ac-pricing-billing" role="group" aria-label="Billing period">
<button type="button" aria-pressed={billing === 'monthly'} onClick={() => changeBilling('monthly')}>Monthly</button>
<button type="button" aria-pressed={billing === 'yearly'} onClick={() => changeBilling('yearly')}>Yearly</button>
</div>
</header>
<div className="ac-pricing-grid">
{plans.map(plan => {
const amount = billing === 'yearly' ? plan.yearly : plan.monthly;
const saving = Math.round((plan.monthly * 12 - plan.yearly) * precision) / precision;
return (
<article className={`ac-pricing-card${plan.featured ? ' ac-pricing-featured' : ''}`} key={plan.id}>
<div className="ac-pricing-card-heading">
<h3>{plan.name}</h3>
{plan.featured && <span className="ac-pricing-badge">A little more room</span>}
</div>
<p className="ac-pricing-description">{plan.description}</p>
<div className="ac-pricing-price" aria-live="polite" aria-atomic="true">
<p><strong>{format.format(amount)}</strong><span> / {billing === 'yearly' ? 'year' : 'month'}</span></p>
<span className="ac-pricing-billing-note">{billing === 'yearly' ? 'Billed annually' : 'Billed monthly'}</span>
{billing === 'yearly' && saving > 0 && <span className="ac-pricing-saving">Save {format.format(saving)} per year</span>}
</div>
<ul className="ac-pricing-features" aria-label={`${plan.name} includes`}>
{plan.features.map((feature, index) => <li key={`${feature}-${index}`}><span aria-hidden="true">✓</span>{feature}</li>)}
</ul>
<a className="ac-pricing-cta" href={plan.href} onClick={event => onSelect?.(plan, billing, event)}>
{plan.ctaLabel ?? `Choose ${plan.name}`}<span aria-hidden="true">↗</span>
</a>
</article>
);
})}
</div>
</section>
);
}
Installation
Run this in your shadcn-configured React project. The component and stylesheet are added together.
npx shadcn@latest add http://localhost:5173/r/pricing-plans.jsonThis command uses the current site address. For localhost, keep this preview running on the same computer. Files follow your configured components alias.
Usage
Import the component and make the details your own.
import { PricingPlans } from '@/components/anticraft/PricingPlans';
export function Example() {
return (
<PricingPlans
title="Make room for more."
currency="USD"
locale="en-US"
plans={[
{
id: 'starter', name: 'Starter',
description: 'A little structure for your next big idea.',
monthly: 12, yearly: 120,
features: ['3 active projects', 'Your own workspace', 'Unlimited notes'],
href: '/signup?plan=starter', ctaLabel: 'Choose Starter',
},
{
id: 'studio', name: 'Studio',
description: 'More space for the work you do together.',
monthly: 29, yearly: 288, featured: true,
features: ['Unlimited projects', '5 team members', 'Shared project timelines'],
href: '/signup?plan=studio', ctaLabel: 'Choose Studio',
},
{
id: 'scale', name: 'Scale',
description: 'A shared home for a growing company.',
monthly: 59, yearly: 588,
features: ['Unlimited projects', '20 team members', 'Workspace permissions'],
href: '/signup?plan=scale', ctaLabel: 'Choose Scale',
},
]}
/>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
plans | PricingPlan[] | Required | Plan records with unique id, name, description, monthly and yearly amounts, a features string array, and a real href. An empty array renders nothing. Supply finite, nonnegative amounts in the currency’s main unit, for example 12 for twelve dollars. |
plans[].monthly / yearly | number | Required | The actual monthly charge and the actual total annual charge. Yearly pricing displays the annual total with “/ year”, never an implied monthly equivalent. A positive annual saving is calculated from monthly × 12 minus yearly and rounded to the currency precision. |
plans[].features | string[] | Required | Visible features included in that plan, rendered as a semantic list. Checkmarks are decorative; write clear feature descriptions. |
plans[].href / ctaLabel | string | Required / 'Choose ' + name | A real signup, enquiry, or plan-detail destination and an optional visible link label. These remain ordinary anchors. Use onSelect in a Client Component if the destination needs the currently selected billing period. |
plans[].featured | boolean | false | Highlights a plan with a tinted background and a “A little more room” label. It does not change the order or select a plan for the visitor. |
title | string | 'A plan for your next chapter' | The visible section heading and accessible section name. |
currency / locale | string | 'USD' / 'en-US' | An ISO currency code and a locale accepted by Intl.NumberFormat. Explicit defaults keep formatting consistent between server and browser; the component does not detect a visitor’s location or convert currencies. |
defaultBilling | 'monthly' | 'yearly' | 'monthly' | Initial billing period. Native pressed buttons switch the displayed prices and billing notes. |
onBillingChange | (period: BillingPeriod) => void | undefined | Called when the visitor changes billing period. Pass callbacks from a Client Component when using a framework with a server/client boundary. |
onSelect | (plan: PricingPlan, period: BillingPeriod, event: MouseEvent<HTMLAnchorElement>) => void | undefined | Receives the selected plan, current billing period, and native React anchor event. Navigation continues unless your handler prevents it. Connect this to your own signup or checkout flow; the component does not collect payments. |
className | string | '' | Additional classes. Theme with --ac-pricing-surface, --ac-pricing-card, --ac-pricing-ink, --ac-pricing-muted, --ac-pricing-accent, --ac-pricing-featured, --ac-pricing-line, and --ac-pricing-font. |
Motion & accessibility
Monthly and Yearly are ordinary buttons with pressed states. Prices update in polite live regions; annual savings appear only when the annual total costs less than twelve monthly payments. Plan links remain keyboard and browser friendly. Below a 760px component width, cards become a touch-scrollable rail with scroll snapping; Tab brings each link into view. The rail keeps cards readable inside narrow sections. Reduced motion removes button and link transitions. The demo opens example plan details in a native dialog with Escape, focus containment, and focus restoration.
Use semantic content and meaningful labels in your composition. Check contrast and keyboard behavior with your own styles and content.