Timace

Pricing

Pricing

One price for the whole team

Start free, and move up when the work does. Yearly billing takes a fifth off.

components/ui/pricing.tsx
"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>
  );
}
styles/timace-ui.css
: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.