Starter includes
- 3 projects
- Community help
- Your own domain
Start free, and move up when the work does. Yearly billing takes a fifth off.
Starter includes
Team includes
Business includes
"use client";
import { useState, type ReactNode } from "react";
export interface PricingPlan {
name: string;
line: string;
/** What a month costs when paid by the month, and when paid by the year. */
monthly: number;
yearly: number;
action: string;
href?: string;
includes: string[];
/** The plan most people should take. It is raised, lit and can carry a badge. */
featured?: boolean;
badge?: string;
}
export const DEFAULT_PLANS: PricingPlan[] = [
{ name: "Starter", line: "For trying it on a real project.", monthly: 0, yearly: 0, action: "Start free", includes: ["3 projects", "Community help", "Your own domain"] },
{ name: "Team", line: "For a group that ships together.", monthly: 25, yearly: 20, action: "Choose Team", featured: true, badge: "Most chosen", includes: ["Unlimited projects", "5 seats", "Priority help", "Shared libraries", "Usage reports"] },
{ name: "Business", line: "For more control and a named contact.", monthly: 80, yearly: 64, action: "Talk to us", includes: ["Everything in Team", "Single sign-on", "Audit log", "Unlimited seats"] },
];
// A button with a little depth: a highlight along the top edge, a shade along the bottom, and a soft cast in its own colour.
const raised =
"bg-(--tui-pricing-accent) text-(--tui-pricing-accent-fg) [background-image:linear-gradient(to_bottom,rgb(255_255_255_/_0.18),rgb(255_255_255_/_0)_55%,rgb(0_0_0_/_0.14))] [box-shadow:inset_0_1px_0_rgb(255_255_255_/_0.24),inset_0_-1px_0_rgb(0_0_0_/_0.2),0_1px_2px_rgb(0_0_0_/_0.2),0_8px_16px_-8px_var(--tui-pricing-accent)] hover:brightness-110";
const quiet = "bg-(--tui-pricing-plan-bg) text-(--tui-pricing-fg) [box-shadow:inset_0_0_0_var(--tui-pricing-border-width)_var(--tui-pricing-border),0_1px_2px_rgb(0_0_0_/_0.05)] hover:bg-(--tui-pricing-shell-bg)";
export interface PricingProps {
eyebrow?: ReactNode;
title?: ReactNode;
description?: ReactNode;
plans?: PricingPlan[];
/** A sign before every price. */
currency?: string;
className?: string;
}
/**
* Plans side by side with one switch for how they are billed. Each plan is
* two layers: a raised panel with the price and the button, sitting in a
* shell that lists what the plan includes. The prices change in place when
* the switch does, so the cards hold still.
*/
export function Pricing({ eyebrow, title, description, plans = DEFAULT_PLANS, currency = "$", className }: PricingProps) {
const [yearly, setYearly] = useState(false);
const saving = Math.max(0, ...plans.map((plan) => (plan.monthly ? Math.round((1 - plan.yearly / plan.monthly) * 100) : 0)));
return (
<section data-slot="pricing" className={["flex w-full flex-col items-center gap-(--tui-pricing-gap) bg-(--tui-pricing-bg) px-6 py-(--tui-pricing-padding-y) font-(family-name:--tui-font) text-(--tui-pricing-fg)", className].filter(Boolean).join(" ")}>
{(eyebrow || title || description) && (
<header className="flex max-w-[42rem] flex-col items-center gap-4 text-center">
{eyebrow && <span className="inline-flex h-7 items-center rounded-full px-3 text-[12px] font-semibold tracking-[0.08em] text-(--tui-pricing-accent) uppercase [box-shadow:inset_0_0_0_var(--tui-pricing-border-width)_var(--tui-pricing-border)]">{eyebrow}</span>}
{title && <h2 className="m-0 text-(length:--tui-pricing-title-size) leading-[1.08] font-semibold tracking-[-0.03em] text-balance">{title}</h2>}
{description && <p className="m-0 text-[17px] leading-relaxed text-pretty text-(--tui-pricing-muted)">{description}</p>}
</header>
)}
<div role="group" aria-label="Billing" data-slot="pricing-switch" className="relative grid grid-cols-2 rounded-full bg-(--tui-pricing-plan-bg) p-1 [box-shadow:inset_0_0_0_var(--tui-pricing-border-width)_var(--tui-pricing-border),0_1px_2px_rgb(0_0_0_/_0.05)]">
<span aria-hidden className={`absolute inset-y-1 left-1 w-[calc(50%_-_4px)] rounded-full transition-[translate] duration-300 ease-[cubic-bezier(0.3,1.3,0.5,1)] ${raised} ${yearly ? "translate-x-full" : ""}`} />
{[false, true].map((value) => (
<button
key={String(value)}
type="button"
aria-pressed={yearly === value}
onClick={() => setYearly(value)}
className={`relative z-10 flex h-9 items-center justify-center gap-2 rounded-full border-none bg-transparent px-5 text-[14px] font-semibold transition-colors duration-200 outline-none focus-visible:[outline:2px_solid_var(--tui-ring)] ${yearly === value ? "text-(--tui-pricing-accent-fg)" : "text-(--tui-pricing-muted) hover:text-(--tui-pricing-fg)"}`}
>
{value ? "Yearly" : "Monthly"}
{value && saving > 0 && <span className={`rounded-full px-1.5 py-0.5 text-[11px] leading-none font-semibold tabular-nums ${yearly ? "bg-[rgb(255_255_255_/_0.22)]" : "bg-(--tui-pricing-glow) text-(--tui-pricing-accent)"}`}>-{saving}%</span>}
</button>
))}
</div>
<div className="grid w-full max-w-(--tui-pricing-width) grid-cols-1 items-stretch gap-(--tui-pricing-plan-gap) md:grid-cols-3">
{plans.map((plan) => (
<article
key={plan.name}
data-slot="pricing-card"
data-featured={plan.featured || undefined}
className="relative flex flex-col rounded-(--tui-pricing-radius) bg-(--tui-pricing-shell-bg) p-1.5 [box-shadow:inset_0_0_0_var(--tui-pricing-border-width)_var(--tui-pricing-border)] data-featured:shadow-(--tui-pricing-featured-shadow) md:data-featured:-my-2.5"
>
{plan.featured && plan.badge && (
<span className={`absolute -top-3 left-1/2 z-10 inline-flex h-6 -translate-x-1/2 items-center gap-1.5 rounded-full px-3 text-[11px] font-semibold tracking-[0.07em] whitespace-nowrap uppercase ${raised}`}>
<svg aria-hidden viewBox="0 0 16 16" fill="currentColor" className="size-3">
<path d="M9 1.500L3.500 9H8l-1 5.500L12.500 7H8z" />
</svg>
{plan.badge}
</span>
)}
<div data-slot="pricing-panel" className="relative flex flex-col gap-6 overflow-hidden rounded-[calc(var(--tui-pricing-radius)_-_6px)] bg-(--tui-pricing-plan-bg) p-(--tui-pricing-padding) [box-shadow:0_0_0_var(--tui-pricing-border-width)_var(--tui-pricing-border),0_1px_2px_rgb(0_0_0_/_0.05),0_8px_20px_-12px_rgb(0_0_0_/_0.18)]">
{plan.featured && <span aria-hidden className="pointer-events-none absolute -top-28 left-1/2 size-72 -translate-x-1/2 rounded-full bg-(--tui-pricing-glow) blur-3xl" />}
<header className="relative flex min-h-[5.25rem] flex-col gap-1.5">
<h3 className="m-0 text-[19px] font-semibold tracking-[-0.01em]">{plan.name}</h3>
<p className="m-0 text-[14px] leading-normal text-(--tui-pricing-muted)">{plan.line}</p>
</header>
<p className="relative m-0 flex items-baseline gap-1.5">
<span key={String(yearly)} className="text-(length:--tui-pricing-price-size) leading-none font-semibold tracking-[-0.035em] tabular-nums [animation:tui-price_0.35s_cubic-bezier(0.2,0.7,0.2,1)] motion-reduce:[animation:none]">
{currency}
{yearly ? plan.yearly : plan.monthly}
</span>
<span className="text-[14px] text-(--tui-pricing-muted)">/ month</span>
</p>
<a href={plan.href ?? "#"} className={`relative inline-flex h-11 items-center justify-center gap-2 rounded-[calc(var(--tui-pricing-radius)_*_0.45)] text-[14px] font-semibold no-underline transition-[filter,background-color,scale] duration-(--tui-duration) active:scale-[0.985] ${plan.featured ? raised : quiet}`}>
{plan.action}
{plan.featured && (
<svg aria-hidden viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="size-3.5">
<path d="M5 11l6-6M6 5h5v5" />
</svg>
)}
</a>
</div>
<div className="flex flex-1 flex-col gap-3.5 px-(--tui-pricing-padding) pt-6 pb-(--tui-pricing-padding)">
<p className="m-0 text-[11px] font-semibold tracking-[0.09em] text-(--tui-pricing-muted) uppercase">{plan.name} includes</p>
<ul className="m-0 flex list-none flex-col gap-3 p-0 text-[14px]">
{plan.includes.map((item) => (
<li key={item} className="flex items-center gap-2.5">
<span aria-hidden className="flex size-[18px] shrink-0 items-center justify-center rounded-full bg-(--tui-pricing-glow) text-(--tui-pricing-accent)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" className="size-2.5">
<path d="M3.500 8.500l3 3 6-7" />
</svg>
</span>
{item}
</li>
))}
</ul>
</div>
</article>
))}
</div>
</section>
);
}:root {
color-scheme: light;
--tui-bg: #ffffff;
--tui-fg: #0a0a0a;
--tui-muted: #f5f5f5;
--tui-muted-fg: #737373;
--tui-border: #e5e5e5;
--tui-primary: #171717;
--tui-primary-fg: #fafafa;
--tui-ring: #a3a3a3;
--tui-success: #16a34a;
--tui-warning: #d97706;
--tui-danger: #dc2626;
--tui-radius: 8px;
--tui-border-width: 1px;
--tui-space: 4px;
--tui-shadow: none;
--tui-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--tui-text: 14px;
--tui-duration: 150ms;
/* Pricing */
--tui-pricing-padding-y: calc(var(--tui-space) * 20);
--tui-pricing-gap: calc(var(--tui-space) * 12);
--tui-pricing-plan-gap: calc(var(--tui-space) * 5);
--tui-pricing-width: 1040px;
--tui-pricing-padding: calc(var(--tui-space) * 7);
--tui-pricing-radius: calc(var(--tui-radius) * 2.5);
--tui-pricing-border-width: var(--tui-border-width);
--tui-pricing-featured-shadow: 0 12px 32px rgb(0 0 0 / 0.14);
--tui-pricing-price-size: 48px;
--tui-pricing-title-size: 40px;
--tui-pricing-bg: var(--tui-bg);
--tui-pricing-plan-bg: var(--tui-bg);
--tui-pricing-fg: var(--tui-fg);
--tui-pricing-muted: var(--tui-muted-fg);
--tui-pricing-border: var(--tui-border);
--tui-pricing-shell-bg: var(--tui-muted);
--tui-pricing-glow: color-mix(in srgb, var(--tui-primary) 12%, transparent);
--tui-pricing-accent: var(--tui-primary);
--tui-pricing-accent-fg: var(--tui-primary-fg);
}
.dark,
[data-theme="dark"] {
color-scheme: dark;
--tui-bg: #0a0a0a;
--tui-fg: #fafafa;
--tui-muted: #1c1c1c;
--tui-muted-fg: #a3a3a3;
--tui-border: #2a2a2a;
--tui-primary: #fafafa;
--tui-primary-fg: #171717;
--tui-ring: #737373;
--tui-success: #22c55e;
--tui-warning: #f59e0b;
--tui-danger: #ef4444;
}
@keyframes tui-price {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}Needs Tailwind CSS v4. Import the stylesheet once, after Tailwind.