Components

Getting started

Blocks3

Components154

More

Plans that turn into their own checkout: pick one and its card becomes the order, seats and totals rolling, then Continue opens your checkout.

Used forPricing pagesBilling settingsUpgrade promptsPer-seat SaaS

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/pricing-plans.json

Templates

Need the whole page?

43 Next.js landing pages, each designed from scratch. Try any of them live; one is $39, all of them $99, and 5 are free.

All 43 templates

Where it’s used

  • Pricing pages

    Plans with a monthly/yearly switch where picking one starts checkout right there.

  • Billing settings

    The account’s current plan marked, with Upgrade and Switch on the others.

  • Upgrade prompts

    Shown when someone hits a limit, so they can upgrade without leaving.

  • Per-seat SaaS

    Team plans priced per seat, with the total worked out as seats change.

What it handles for you

A pricing section is usually cards, a toggle and a link to a checkout page, with the seat maths left for the checkout to explain. This one carries the choice through to checkout in the same card, against your own payment provider.

Instead of wiring upplan cardsa billing togglea seats pickeran order summarya checkout redirect buttona current-plan state
  1. The card becomes the checkout

    Picking a plan turns its card into an order slip in place; the other plans step back, Escape or × goes back.

  2. Seats and totals that add up

    Per-seat plans get a stepper with your minimum and maximum; line items and the total roll as they change.

  3. Yearly that shows its saving

    The toggle’s badge is worked out from your prices, and the slip says how much yearly saves for these seats.

  4. Checkout through your provider

    onCheckout gets the plan, billing, seats and total; create the session there and redirect.

  5. Failures stay put

    If checkout can’t start, the slip keeps everything and shows your message under Try again.

  6. A billing page too

    With currentPlanId the current plan says Your plan and the others read Upgrade to or Switch to.

Overview

Most pricing sections end at a button that throws you to another page, and the price you saw is gone. Here the plan you pick turns into its own checkout. Its card becomes a slip on the same surface: seats on a stepper for per-seat plans, the line items and what’s due today rolling as you change seats or switch to yearly, and what yearly saves you, while the other plans step back. Continue goes Opening checkout → Redirecting through your onCheckout, where you create the Stripe or Paddle session and redirect; if that fails the slip stays and says why. The billing switch rolls every price the way it moved and its badge works out the saving from your own prices. The featured plan is the lit surface, ruled with fine ledger hairlines, and with currentPlanId the same block becomes a billing settings page: Your plan, Upgrade to, Switch to.

States

plans
Hairline cards; the featured plan is lit from its top corner with fine ledger rules fading from the top right. Monthly/Yearly rolls every price and slides the billing note in from the side the switch moved.
checkout
The picked card turns into a slip in 460ms (the plan face slides left through a blur, the slip comes in from the right, the height eases); the other plans fade to half and settle back a little.
seats
The stepper changes the seat count; the line ("$19 × 4 seats × 12 months"), the saving and Due today roll to the new totals.
pending
Continue morphs to Opening checkout with a spinner; seats and billing are locked.
redirecting
Once onCheckout resolves, a check draws itself and the button reads Redirecting.
error
The button shakes into Try again and your message replaces "Cancel any time."; nothing else changes.
current plan
With currentPlanId the plan reads Your plan with a quiet, disabled button; others offer Upgrade to or Switch to by price.

Usage

usage.tsx
import { PricingPlans } from "@/components/pricing-plans";<PricingPlans  plans={plans}  onCheckout={async ({ plan, billing, seats }) => {    const { url } = await createCheckoutSession({ plan: plan.id, billing, seats });    window.location.href = url;  }}/>

Depends on lucide-react, installed for you by the CLI along with the keyframes it needs.

Works with

With Stripe Checkout

Create the Checkout Session in a server action and send the visitor to its URL. Throwing shows your message in the slip.

example.tsx
// app/pricing/actions.ts"use server";import Stripe from "stripe";const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);const PRICES = { pro: { monthly: "price_pro_m", yearly: "price_pro_y" }, team: { monthly: "price_team_m", yearly: "price_team_y" } };export async function startCheckout(plan: "pro" | "team", billing: "monthly" | "yearly", seats: number) {  const session = await stripe.checkout.sessions.create({    mode: "subscription",    line_items: [{ price: PRICES[plan][billing], quantity: seats }],    success_url: "https://example.com/welcome",    cancel_url: "https://example.com/pricing",  });  return session.url!;}// app/pricing/plans.tsx"use client";import { PricingPlans } from "@/components/pricing-plans";import { startCheckout } from "./actions";export function Plans() {  return (    <PricingPlans      plans={plans}      onCheckout={async ({ plan, billing, seats }) => {        const url = await startCheckout(plan.id as "pro" | "team", billing, seats);        window.location.href = url;      }}    />  );}

Props

PropTypeDescription
plansPricingPlan[]{ id, name, description?, price: { monthly, yearly }, perSeat?, minSeats?, maxSeats?, features, featured?, badge?, cta?, href? }. Prices are per month (per seat when perSeat); yearly is the monthly rate when billed yearly.
onCheckout(checkout: PricingCheckout) => Promise<void>Gets { plan, billing, seats, total }. Start your checkout and redirect; throw an Error to show its message. Free plans call it straight from their button.
billing / defaultBilling / onBillingChange"monthly" | "yearly"default — / "monthly"The billing period; controlled or not.
currentPlanIdstringMarks the account’s plan and turns the other buttons into Upgrade to / Switch to.
currency / localestringdefault "USD" / browserFormats every price with Intl.

Accessibility and motion

  • Each plan is a list item with its name as a heading; the checkout slip is a labelled group that takes focus when it opens, and Escape or × returns focus to the plan’s button.
  • Prices and totals are read as whole amounts; the billing note is plain text, never a live region, and the error is an alert.
  • A plan with href is a real link (Contact sales); the current plan’s button is disabled and says so.
  • The featured plan’s light comes from your theme: --primary-foreground mixed into --primary for the lit corner, 1px light edges and ledger rules in the same colour, so it follows your brand in light and dark. No drop shadows, glow or blur.
  • Built from Pricing toggle, Number roll, Stepper input, Status button and Text morph, which the CLI installs with it. No animation library; with reduced motion the card and slip crossfade in place.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs Number roll, Pricing toggle, Status button, Stepper input, Text morph.

Source

components/pricing-plans.tsx
"use client";import * as React from "react";import { Check, X } from "lucide-react";import { Faces, FACES_MORPH } from "./faces";import { NumberRoll } from "./number-roll";import { PricingToggle } from "./pricing-toggle";import { StatusButton, type ActionStatus } from "./status-button";import { StepperInput } from "./stepper-input";import { TextMorph } from "./text-morph";import { INTERACTION_FOCUS, INTERACTION_PRESS, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * PRICING PLANS: plans that turn into their own checkout * *   plans     flat hairline cards; the featured plan is the lit *             surface, ruled with fine ledger hairlines. Billing *             toggles roll every price the way it moved *   choose    the plan you pick turns into its checkout in place, *             a slip on the same card: seats on a stepper, the *             line items and the total rolling as seats or billing *             change, while the other plans step back *   checkout  Continue goes Opening checkout → Redirecting through *             your onCheckout (create the Stripe or Paddle session *             there); a failure keeps the slip and says why *   current   with currentPlanId, buttons read Current plan, *             Upgrade or Downgrade, for a billing settings page * ───────────────────────────────────────────────────────── */export type Billing = "monthly" | "yearly";export interface PricingPlan {  id: string;  name: string;  description?: string;  /** Per month, in major units; per seat when perSeat. Yearly is the monthly rate when billed yearly. */  price: { monthly: number; yearly: number };  perSeat?: boolean;  minSeats?: number;  maxSeats?: number;  features: string[];  /** The plan on the lit surface. */  featured?: boolean;  /** A short tag beside the name, e.g. "Most popular". */  badge?: string;  /** Button label; defaults to "Get <name>", "Start free" or Upgrade / Downgrade. */  cta?: string;  /** A link instead of checkout, e.g. "Contact sales" for an enterprise plan. */  href?: string;}export interface PricingCheckout {  plan: PricingPlan;  billing: Billing;  seats: number;  /** What's due now, in major units: a month, or a year when billed yearly. */  total: number;}export interface PricingPlansProps {  plans: PricingPlan[];  billing?: Billing;  defaultBilling?: Billing;  onBillingChange?: (billing: Billing) => void;  /** The plan the account is on, for a billing settings page. */  currentPlanId?: string;  /** Start checkout; resolve once you're redirecting (or done), throw an Error to show its message. */  onCheckout?: (checkout: PricingCheckout) => Promise<void>;  currency?: string;  locale?: string;  className?: string;}const EASE = "cubic-bezier(0.16,1,0.3,1)";const mix = (color: string, amount: number, into = "transparent") => `color-mix(in oklab, ${color} ${amount}%, ${into})`;/* The lit plan: --primary with a lit corner from --primary-foreground, a deeper lower edge and 1px light edges. */const LIT_SURFACE: React.CSSProperties = {  backgroundColor: "var(--primary)",  backgroundImage: `radial-gradient(120% 90% at 0% 0%, ${mix("var(--primary-foreground)", 22)} 0%, transparent 55%), linear-gradient(170deg, transparent 45%, ${mix("black", 22)} 100%)`,  boxShadow: `inset 0 1px 0 ${mix("var(--primary-foreground)", 36)}, inset 0 0 0 1px ${mix("var(--primary-foreground)", 12)}`,};/* Its signature: fine ledger rules, fading out from the top right. */const LEDGER: React.CSSProperties = {  backgroundImage: `repeating-linear-gradient(180deg, transparent 0 11px, ${mix("var(--primary-foreground)", 10)} 11px 12px)`,  maskImage: "radial-gradient(130% 80% at 100% 0%, black 0%, transparent 70%)",  WebkitMaskImage: "radial-gradient(130% 80% at 100% 0%, black 0%, transparent 70%)",};const LIT_ACTION: React.CSSProperties = {  backgroundImage: `linear-gradient(180deg, ${mix("var(--primary-foreground)", 16)}, transparent 60%)`,  boxShadow: `inset 0 1px 0 ${mix("var(--primary-foreground)", 32)}, inset 0 0 0 1px ${mix("black", 10)}`,};const COLS: Record<number, string> = { 1: "", 2: "@[640px]:grid-cols-2", 3: "@[640px]:grid-cols-3", 4: "@[760px]:grid-cols-2 @[1040px]:grid-cols-4" };function useMoney(locale: string | undefined, currency: string) {  return React.useMemo(() => {    const whole = new Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits: 0 });    const cents = new Intl.NumberFormat(locale, { style: "currency", currency });    return { format: (n: number) => (n % 1 ? cents : whole).format(n) };  }, [locale, currency]);}const priceFormat = (currency: string, n: number): Intl.NumberFormatOptions => ({ style: "currency", currency, minimumFractionDigits: n % 1 ? 2 : 0, maximumFractionDigits: n % 1 ? 2 : 0 });export function PricingPlans({  plans,  billing: billingProp,  defaultBilling = "monthly",  onBillingChange,  currentPlanId,  onCheckout,  currency = "USD",  locale,  className = "",}: PricingPlansProps) {  const reduced = useInteractionReducedMotion();  const money = useMoney(locale, currency);  const baseId = React.useId();  const [ownBilling, setOwnBilling] = React.useState<Billing>(defaultBilling);  const billing = billingProp ?? ownBilling;  const [dir, setDir] = React.useState(1);  const [openId, setOpenId] = React.useState<string | null>(null);  const [seats, setSeats] = React.useState<Record<string, number>>({});  const [status, setStatus] = React.useState<{ id: string; status: ActionStatus; error?: string } | null>(null);  const [ready, setReady] = React.useState(false);  const slipRefs = React.useRef(new Map<string, HTMLDivElement>());  const ctaRefs = React.useRef(new Map<string, HTMLElement>());  React.useEffect(() => {    const frame = requestAnimationFrame(() => setReady(true));    return () => cancelAnimationFrame(frame);  }, []);  React.useEffect(() => {    if (!ready || !openId) return;    slipRefs.current.get(openId)?.focus({ preventScroll: true });  }, [openId, ready]);  const setBilling = (next: Billing) => {    if (next === billing) return;    setDir(next === "yearly" ? 1 : -1);    if (billingProp === undefined) setOwnBilling(next);    onBillingChange?.(next);  };  // The toggle's badge says what yearly saves, from the plans themselves.  const save = Math.max(0, ...plans.filter((p) => p.price.monthly > 0).map((p) => Math.round((1 - p.price.yearly / p.price.monthly) * 100)));  const options = React.useMemo(() => [{ value: "monthly", label: "Monthly" }, { value: "yearly", label: "Yearly", ...(save > 0 ? { badge: `Save ${save}%` } : {}) }], [save]);  const current = plans.find((p) => p.id === currentPlanId);  const seatsOf = (p: PricingPlan) => (p.perSeat ? Math.max(p.minSeats ?? 1, seats[p.id] ?? p.minSeats ?? 1) : 1);  const totalOf = (p: PricingPlan) => p.price[billing] * seatsOf(p) * (billing === "yearly" ? 12 : 1);  const busy = status?.status === "pending" || status?.status === "success";  const close = () => {    if (busy || !openId) return;    const id = openId;    setOpenId(null);    setStatus(null);    window.setTimeout(() => ctaRefs.current.get(id)?.focus({ preventScroll: true }), reduced ? 0 : FACES_MORPH);  };  const checkout = async (p: PricingPlan) => {    if (busy) return;    setStatus({ id: p.id, status: "pending" });    try {      await onCheckout?.({ plan: p, billing, seats: seatsOf(p), total: totalOf(p) });      setStatus({ id: p.id, status: "success" });    } catch (e) {      setStatus({ id: p.id, status: "error", error: e instanceof Error && e.message ? e.message : "Couldn’t start checkout. Try again." });    }  };  const ctaLabel = (p: PricingPlan) => {    if (p.id === currentPlanId) return "Current plan";    if (current) return p.price.monthly >= current.price.monthly ? `Upgrade to ${p.name}` : `Switch to ${p.name}`;    return p.cta ?? (p.price.monthly === 0 ? "Start free" : `Get ${p.name}`);  };  return (    <div      className={`@container w-full ${className}`}      onKeyDown={(e) => {        if (e.key === "Escape" && openId) {          e.preventDefault();          close();        }      }}    >      <div className="flex justify-center">        <PricingToggle value={billing} onValueChange={(v) => setBilling(v as Billing)} options={options} />      </div>      <ul className={`mt-6 grid grid-cols-1 items-stretch gap-3 ${COLS[Math.min(plans.length, 4)] ?? ""}`}>        {plans.map((p) => {          const lit = Boolean(p.featured);          const isOpen = openId === p.id;          const isCurrent = p.id === currentPlanId;          const amount = p.price[billing];          const free = p.price.monthly === 0;          const st = status?.id === p.id ? status : null;          const n = seatsOf(p);          const total = totalOf(p);          const note = free ? "Free forever" : billing === "yearly" ? `${money.format(amount * 12)}${p.perSeat ? " per seat" : ""} billed yearly` : p.perSeat ? "Per seat, billed monthly" : "Billed monthly";          const muted = lit ? "text-primary-foreground/70" : "text-muted-foreground";          const dimmed = openId !== null && !isOpen;          return (            <li              key={p.id}              className={`relative flex flex-col overflow-clip rounded-[28px] ${isOpen ? "self-start" : ""} ${lit ? "text-primary-foreground" : "border border-border bg-card text-card-foreground"}`}              style={{                ...(lit ? LIT_SURFACE : {}),                opacity: dimmed ? 0.5 : 1,                transform: dimmed && !reduced ? "scale(0.985)" : "none",                transition: reduced ? "opacity 160ms" : `opacity 320ms ${EASE}, transform ${FACES_MORPH}ms ${EASE}`,              }}            >              {lit && <span aria-hidden="true" className="pointer-events-none absolute inset-0" style={LEDGER} />}              <Faces active={isOpen ? 1 : 0} reduced={reduced} ready={ready} className="flex-1">                {/* The plan. */}                <div className="flex h-full flex-col p-6">                  <div className="flex items-center gap-2">                    <h3 id={`${baseId}-${p.id}`} className="text-[16px] font-medium tracking-[-0.01em]">                      {p.name}                    </h3>                    {(p.badge || isCurrent) && (                      <span className={`rounded-full px-2 py-0.5 text-[11.5px] font-medium ${lit ? "bg-primary-foreground/15 text-primary-foreground" : "bg-primary/10 text-primary"}`}>                        {isCurrent ? "Your plan" : p.badge}                      </span>                    )}                  </div>                  {p.description && <p className={`mt-1.5 text-[13.5px] leading-relaxed ${muted}`}>{p.description}</p>}                  <div className="mt-6 flex items-baseline gap-1.5">                    <span className="text-[clamp(40px,7cqw,52px)] font-medium leading-none tracking-[-0.045em] tabular-nums">                      {free ? "Free" : <NumberRoll value={amount} locales={locale} format={priceFormat(currency, amount)} />}                    </span>                    {!free && <span className={`text-[13.5px] ${muted}`}>{p.perSeat ? "/seat/mo" : "/mo"}</span>}                  </div>                  <div className={`relative mt-2 h-5 overflow-hidden text-[12.5px] ${muted}`}>                    <span key={note} className="absolute inset-0 truncate motion-safe:animate-[ui-note-in_380ms_cubic-bezier(0.23,1,0.32,1)_both]" style={{ "--ui-from": `${dir > 0 ? 100 : -100}%` } as React.CSSProperties}>                      {note}                    </span>                  </div>                  <div className="mt-6">                    {p.href && !isCurrent ? (                      <a                        ref={(el) => {                          if (el) ctaRefs.current.set(p.id, el);                        }}                        href={p.href}                        className={`flex h-12 w-full items-center justify-center rounded-full text-[15px] font-medium ${INTERACTION_PRESS} ${INTERACTION_FOCUS} ${lit ? "bg-primary-foreground text-primary" : "text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent"}`}                      >                        {p.cta ?? "Contact sales"}                      </a>                    ) : free && !isCurrent ? (                      <StatusButton                        status={st?.status ?? "idle"}                        labels={{ idle: ctaLabel(p), pending: "Setting up", success: "You’re in", error: "Try again" }}                        variant="outline"                        onClick={() => void checkout(p)}                        className="h-12 w-full text-[15px]"                      />                    ) : (                      <button                        ref={(el) => {                          if (el) ctaRefs.current.set(p.id, el);                        }}                        type="button"                        disabled={isCurrent}                        aria-expanded={isOpen}                        onClick={() => {                          setStatus(null);                          setOpenId(p.id);                        }}                        className={`flex h-12 w-full cursor-pointer items-center justify-center rounded-full text-[15px] font-medium disabled:cursor-default ${INTERACTION_PRESS} ${INTERACTION_FOCUS} ${                          isCurrent                            ? lit                              ? "bg-primary-foreground/15 text-primary-foreground/80"                              : "bg-muted text-muted-foreground"                            : lit                              ? "bg-primary-foreground text-primary hover:bg-primary-foreground/90"                              : "bg-primary text-primary-foreground hover:bg-primary/90"                        }`}                        style={!lit && !isCurrent ? LIT_ACTION : undefined}                      >                        {ctaLabel(p)}                      </button>                    )}                  </div>                  {st?.status === "error" && free && <p role="alert" className="mt-2 text-center text-[12.5px] text-red-600 dark:text-red-400">{st.error}</p>}                  <ul className={`mt-6 space-y-2.5 border-t pt-5 text-[13.5px] ${lit ? "border-primary-foreground/15" : "border-border"}`}>                    {p.features.map((f) => (                      <li key={f} className="flex gap-2.5">                        <span className={`mt-px flex size-[18px] shrink-0 items-center justify-center rounded-full ${lit ? "bg-primary-foreground/15" : "bg-primary/10 text-primary"}`}>                          <Check aria-hidden="true" className="size-3" strokeWidth={2.5} />                        </span>                        <span className={lit ? "text-primary-foreground/90" : "text-foreground"}>{f}</span>                      </li>                    ))}                  </ul>                </div>                {/* Its checkout: a slip on the same card. */}                <div className="p-2.5">                  <div                    ref={(el) => {                      if (el) slipRefs.current.set(p.id, el);                      else slipRefs.current.delete(p.id);                    }}                    tabIndex={-1}                    role="group"                    aria-label={`Checkout for ${p.name}`}                    className="rounded-[20px] border border-border bg-card p-4 text-card-foreground outline-none"                  >                    <div className="flex items-center justify-between gap-3">                      <p className="text-[15px] font-medium tracking-[-0.01em]">                        {p.name} · <TextMorph>{billing === "yearly" ? "Yearly" : "Monthly"}</TextMorph>                      </p>                      <button type="button" aria-label="Back to plans" onClick={close} className={`-mr-1.5 flex size-8 cursor-pointer items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`}>                        <X aria-hidden="true" className="size-4" />                      </button>                    </div>                    {p.perSeat && (                      <div className="mt-4 flex items-center justify-between gap-3">                        <span className="text-[13.5px] text-muted-foreground">Seats</span>                        <StepperInput                          label="Seats"                          value={n}                          onValueChange={(v) => !busy && setSeats((s) => ({ ...s, [p.id]: v }))}                          min={p.minSeats ?? 1}                          max={p.maxSeats ?? 500}                          size="sm"                          disabled={busy}                        />                      </div>                    )}                    <dl className="mt-4 space-y-2 border-t border-border pt-4 text-[13.5px]">                      <div className="flex justify-between gap-3">                        <dt className="text-muted-foreground">                          {money.format(amount)}                          {p.perSeat ? " × " : ""}                          {p.perSeat && <NumberRoll value={n} duration={500} />}                          {p.perSeat ? (n === 1 ? " seat" : " seats") : ""}                          {billing === "yearly" ? " × 12 months" : " a month"}                        </dt>                        <dd className="tabular-nums">                          <NumberRoll value={total} locales={locale} format={priceFormat(currency, total)} duration={700} />                        </dd>                      </div>                      {billing === "monthly" && p.price.yearly < p.price.monthly ? (                        <div className="flex justify-between gap-3 text-[12.5px]">                          <dt className="text-muted-foreground">Yearly saves {money.format((p.price.monthly - p.price.yearly) * 12 * n)}</dt>                          <dd>                            <button type="button" disabled={busy} onClick={() => setBilling("yearly")} className={`cursor-pointer rounded-full text-primary underline-offset-4 hover:underline disabled:cursor-default ${INTERACTION_FOCUS}`}>                              Switch                            </button>                          </dd>                        </div>                      ) : billing === "yearly" ? (                        <div className="flex justify-between gap-3 text-[12.5px]">                          <dt className="text-muted-foreground">You save</dt>                          <dd className="tabular-nums text-emerald-600 dark:text-emerald-400">                            <NumberRoll value={(p.price.monthly - p.price.yearly) * 12 * n} locales={locale} format={priceFormat(currency, (p.price.monthly - p.price.yearly) * 12 * n)} duration={700} />                          </dd>                        </div>                      ) : null}                    </dl>                    <div className="mt-4 flex items-end justify-between gap-3 border-t border-border pt-4">                      <span className="text-[13.5px] text-muted-foreground">Due today</span>                      <span className="text-[32px] font-medium leading-none tracking-[-0.04em] tabular-nums">                        <NumberRoll value={total} locales={locale} format={priceFormat(currency, total)} duration={900} />                      </span>                    </div>                    <StatusButton                      status={st?.status ?? "idle"}                      labels={{ idle: "Continue to checkout", pending: "Opening checkout", success: "Redirecting", error: "Try again" }}                      onClick={() => void checkout(p)}                      className="mt-4 h-12 w-full text-[15px]"                      style={st?.status === "error" ? undefined : LIT_ACTION}                    />                    <p role={st?.status === "error" ? "alert" : undefined} className={`mt-2 min-h-[18px] text-center text-[12.5px] ${st?.status === "error" ? "text-red-600 dark:text-red-400" : "text-muted-foreground"}`}>                      {st?.status === "error" ? st.error : "Cancel any time."}                    </p>                  </div>                </div>              </Faces>            </li>          );        })}      </ul>    </div>  );}export default PricingPlans;
components/faces.tsx
"use client";import * as React from "react";/* ───────────────────────────────────────────────────────── * FACES: the states of one surface, in the same place * * The active face is in flow; the others are laid over it, faded * and nudged aside (faces before it to the left, faces after it to * the right), so switching reads as the surface turning to its * next side. The height eases between the faces' measured heights, * so nothing around it jumps. Used by the blocks (the wallet's * activity and keypad, a pricing card and its checkout, a booking's * times, details and confirmation). * ───────────────────────────────────────────────────────── */const EASE = "cubic-bezier(0.16,1,0.3,1)";export const FACES_MORPH = 460;export function Faces({  active,  reduced,  ready,  className = "",  children,}: {  active: number;  reduced: boolean;  /** False until the first paint, so the faces don't animate into place on mount. */  ready: boolean;  className?: string;  children: React.ReactNode[];}) {  const refs = React.useRef<(HTMLDivElement | null)[]>([]);  const [heights, setHeights] = React.useState<number[] | null>(null);  const count = children.length;  React.useLayoutEffect(() => {    const els = refs.current.slice(0, count);    const observer = new ResizeObserver(() => {      const next = els.map((el) => el?.offsetHeight ?? 0);      setHeights((p) => (p && p.length === next.length && p.every((h, i) => h === next[i]) ? p : next));    });    els.forEach((el) => el && observer.observe(el));    return () => observer.disconnect();  }, [count]);  const face = (i: number): React.CSSProperties => {    const on = i === active;    return {      opacity: on ? 1 : 0,      transform: on || reduced ? "none" : `translateX(${i < active ? -18 : 18}px)`,      filter: on || reduced ? "none" : "blur(4px)",      transition: !ready        ? "none"        : reduced          ? "opacity 160ms ease-out"          : on            ? `opacity 320ms ${EASE} 90ms, transform ${FACES_MORPH}ms ${EASE}, filter 320ms ${EASE} 90ms`            : `opacity 140ms ease-out, transform ${FACES_MORPH}ms ${EASE}, filter 200ms ease-out`,    };  };  return (    <div className={`relative overflow-clip ${className}`} style={{ height: heights ? heights[active] : undefined, transition: ready && !reduced ? `height ${FACES_MORPH}ms ${EASE}` : "none" }}>      {children.map((child, i) => (        <div          key={i}          ref={(el) => {            refs.current[i] = el;          }}          inert={active !== i || undefined}          aria-hidden={active !== i || undefined}          className={active === i ? "relative" : "pointer-events-none absolute inset-x-0 top-0"}          style={face(i)}        >          {child}        </div>      ))}    </div>  );}
components/ui-interaction-motion.ts
'use client';import * as React from 'react';export const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';export const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';/** Thrown things (indicators, thumbs): a slight overshoot on transform only. */export const EASE_THROW = 'cubic-bezier(0.34, 1.36, 0.64, 1)';/** * The press every button primitive shares: a 3% give that eases in and settles back. * Tailwind v4's scale-* writes the `scale` property, so `scale` has to be in the list * or the press snaps. */export const INTERACTION_PRESS =  'transition-[background-color,color,box-shadow,opacity,scale] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] active:scale-[0.97] motion-reduce:transition-none motion-reduce:active:scale-100';export const INTERACTION_FOCUS ='focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';const query = '(prefers-reduced-motion: reduce)';const subscribe = (notify: () => void) => {  const media = window.matchMedia(query);  media.addEventListener('change', notify);  return () => media.removeEventListener('change', notify);};export const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);export function useInteractionActivity<T extends Element>() {  const ref = React.useRef<T>(null);  const [visible, setVisible] = React.useState(false);  const [hidden, setHidden] = React.useState(false);  React.useEffect(() => {    const el = ref.current;    if (!el) return;    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));    observer.observe(el);    const visibility = () => setHidden(document.hidden);    visibility(); document.addEventListener('visibilitychange', visibility);    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };  }, []);  return { ref, active: visible && !hidden };}export function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };}/** Sample recent movement rather than averaging a long drag and losing its release velocity. */export type PointerSample = { x: number; time: number };export function samplePointer(samples: PointerSample[], x: number, time: number) {  samples.push({ x, time });  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();}export function releaseVelocity(samples: PointerSample[], now: number) {  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;  const first = samples[0], last = samples[samples.length - 1];  return (last.x - first.x) / Math.max(1, last.time - first.time);}/** Smooth Apple/rational rubber-band curve: continuous exponential-like resistance without linear kinks. */export function rubberBand(overshoot: number, dimension = 320, constant = 0.55): number {  const d = dimension || 320;  const abs = Math.abs(overshoot);  return Math.sign(overshoot) * (1 - 1 / ((abs * constant) / d + 1)) * d;}/** Projects resting travel from release velocity (velocity in px/ms; time in seconds). */export function projectInertia(distance: number, velocity: number, time = 0.12): number {  return distance + velocity * 1000 * time;}/** Smooths incoming release velocity toward a boundary so springs don't slam or clip into walls. */export function softenVelocity(velocity: number, distanceToTarget: number, factor = 10): number {  const maxAllowed = Math.abs(distanceToTarget) * factor;  return Math.sign(velocity) * Math.min(Math.abs(velocity), maxAllowed);}/** A damped spring sampled by WAAPI: tuned for organic lightness with a hint of life (3% breath). */export function springFrames(  from: number,  to: number,  velocity = 0,  duration = 480,  config: { omega?: number; damping?: number; steps?: number } = {},) {  const { omega = 16, damping = 0.74, steps = 36 } = config;  const damped = omega * Math.sqrt(Math.max(0.001, 1 - damping * damping));  const displacement = from - to;  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;  return Array.from({ length: steps + 1 }, (_, i) => {    const t = (i / steps) * (duration / 1000);    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));    return { value: i === steps ? to : value, offset: i / steps };  });}

Registry item: /r/pricing-plans.json