Components

Getting started

Blocks13

Components154

More

A billing page in one card: the plan with a ring through its billing period, usage, card and invoices, and a cancel that offers a pause first.

Used forBilling settingsCancel flowsDunningMemberships

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/subscription.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

  • Billing settings

    The plan, usage, card and invoices on a SaaS account’s billing page.

  • Cancel flows

    A pause offered before cancelling, with the reason kept for you.

  • Dunning

    A failed payment shown on the plan with a way to update the card.

  • Memberships

    A gym, course or community membership people can pause.

What it handles for you

A billing page is usually a plan card, a few progress bars, a portal link and a cancel modal, with no chance to keep the customer. This block shows where they are in the period and offers a pause before the cancel.

Instead of wiring upa plan cardusage metersa payment method rowan invoice lista cancel modala pause offera cancellation survey
  1. Where the period stands

    A ring fills from periodStart to periodEnd with the days left inside; the status says when it renews, pauses or ends.

  2. Usage that warns

    Each meter rolls its count and turns amber from 90% of the limit.

  3. Card and invoices

    The card on file with Update (your portal) and the latest invoices with their status and a download.

  4. A pause before the cancel

    Cancel plan offers one to three months’ pause with the date billing resumes; onPause gets the months.

  5. A reason you can use

    Cancelling asks why (your reasons) with an optional note; onCancel gets both.

  6. Undo in place

    Paused or ending plans offer Resume now or Keep my plan through onResume.

Overview

Most billing pages are a plan name, a link to the provider’s portal and a cancel button that ends it in one click. Here the plan sits on a lit card with a ring that fills through the billing period and counts the days left in its middle, and a status that morphs between Renews, Trial ends, Paused until and Ends. Under it usage meters roll and turn amber near the limit, the card on file opens your provider’s page to update, and the latest invoices show their status. Cancel plan turns the lower half into “Pause instead?”: pick one, two or three months and the date billing resumes rolls to match. Only past that comes the reason and Cancel subscription, which says how long you keep the plan. A paused or ending plan offers Resume in place. The billing is yours: status and dates in, onPause, onCancel, onResume and onUpdateCard out.

States

active
The lit plan card (name, seats, price, a ring with the days left and “Renews Nov 8”), then usage meters, the card on file, three invoices, Change plan and Cancel plan.
usage
Meters fill from zero on mount (900ms); a meter at 90% or more turns amber with its count in full weight.
pause offer
Cancel plan turns the lower half to “Pause instead?” in 460ms; the month choice is a segmented control and “Billing resumes on …” morphs with it.
cancel
“No, cancel it” turns to the reasons (radio chips) and a note; Cancel subscription goes Cancelling → Cancelled, then the card says Ends and loses some colour.
paused
The status reads Paused until with the date, the ring is full and dimmed, and Resume now replaces Change plan.
past due
Payment failed in amber on the card, the failed invoice marked, and Update on the card opens your portal.

Usage

usage.tsx
import { Subscription } from "@/components/subscription";<Subscription  plan={{ name: "Pro", price: 24, interval: "month" }}  status={sub.status}  periodStart={sub.periodStart}  periodEnd={sub.periodEnd}  onPause={(months) => api.pause(months)}  onCancel={(reason, comment) => api.cancel(reason, comment)}  onResume={() => api.resume()}/>

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

Works with

With your own API

Read the subscription from your server, call it for each action, and refresh. Open your provider’s portal from onUpdateCard.

example.tsx
"use client";import { useState } from "react";import { Subscription, type SubscriptionProps } from "@/components/subscription";type Sub = Pick<SubscriptionProps, "plan" | "status" | "periodStart" | "periodEnd" | "resumesAt" | "usage" | "card" | "invoices">;async function post(path: string, body?: unknown): Promise<Sub> {  const res = await fetch(path, { method: "POST", body: JSON.stringify(body ?? {}) });  const json = await res.json();  if (!res.ok) throw new Error(json.message);  return json;}export function Billing({ initial }: { initial: Sub }) {  const [sub, setSub] = useState(initial);  return (    <Subscription      {...sub}      onChangePlan={() => (window.location.href = "/pricing")}      onUpdateCard={async () => {        const { url } = await (await fetch("/api/billing/portal", { method: "POST" })).json();        window.location.href = url;      }}      onPause={async (months) => setSub(await post("/api/billing/pause", { months }))}      onCancel={async (reason, comment) => setSub(await post("/api/billing/cancel", { reason, comment }))}      onResume={async () => setSub(await post("/api/billing/resume"))}    />  );}

Props

PropTypeDescription
plan{ name, price, interval: "month" | "year", seats? }The plan on the lit card.
status"active" | "trialing" | "past_due" | "paused" | "canceling"Sets the status line and which actions show.
periodStart / periodEnd / resumesAtDate | string | numberThe billing period (the ring and the renew or end date) and, when paused, when billing resumes.
usageSubscriptionUsage[]{ id, label, used, limit, unit? } meters.
card / invoicesSubscriptionCard / SubscriptionInvoice[]{ brand, last4, expMonth, expYear } and { id, date, amount, status, url? }; the latest three invoices show.
onPause / onCancel / onResume(months) / (reason, comment) / () => Promise<unknown>Change the subscription; throw an Error to show its message. Without onPause, Cancel plan goes straight to the reasons.
onUpdateCard / onChangePlan() => Promise<unknown> / () => voidOpen your portal for the card, and go to your plans.
pauseMonths / reasonsnumber[] / string[]default [1, 2, 3] / five common reasonsThe pause lengths offered and the reasons to pick from.
currency / localestringdefault "USD" / browserFormats prices and dates.

Accessibility and motion

  • The status is a status region, so a pause or a cancel is announced once; usage bars are meters with their numbers.
  • The pause offer and the reasons are labelled groups that take focus when the surface turns to them; Keep my plan goes back.
  • Dates are written once mounted, in the visitor’s zone, so the server render never disagrees.
  • Its light comes from your theme: the plan card mixes --primary-foreground into --primary, the ring and its ticks are 3px and hairline strokes in the same light, and an ending plan drains some colour. No drop shadows, glow or blur.
  • Built from Segmented control, Textarea, Number roll, Status button and Text morph, which the CLI installs with it. No animation library; with reduced motion the faces change 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, Segmented control, Status button, Text morph, Textarea.

Source

components/subscription.tsx
"use client";import * as React from "react";import { ArrowDownToLine, ChevronLeft } from "lucide-react";import { Faces, FACES_MORPH } from "./faces";import { NumberRoll } from "./number-roll";import { SegmentedControl } from "./segmented-control";import { StatusButton, type ActionStatus } from "./status-button";import { Textarea } from "./textarea";import { TextMorph } from "./text-morph";import { EASE_THROW, INTERACTION_FOCUS, INTERACTION_PRESS, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * SUBSCRIPTION: a plan you can see through to its next bill * *   plan      the lit card holds the plan, its price and a ring *             that fills through the billing period, the days left *             in its middle; under it usage, the card on file and *             invoices, all flat *   pause     Cancel plan turns the lower half into "Pause *             instead?": pick one, two or three months and the date *             billing resumes rolls to match *   cancel    only past the offer, a reason and a note, then *             Cancel subscription; the card says when it ends *   resume    a paused or ending plan offers Resume in place * * Billing is yours: pass the plan, status, period and usage from * your provider, and do the work in onPause, onCancel, onResume and * onUpdateCard (open your provider's portal there). Throw an Error * and its message shows where it happened. * ───────────────────────────────────────────────────────── */export type SubscriptionStatus = "active" | "trialing" | "past_due" | "paused" | "canceling";export interface SubscriptionPlan {  name: string;  /** Per interval, major units. */  price: number;  interval: "month" | "year";  seats?: number;}export interface SubscriptionUsage {  id: string;  label: string;  used: number;  limit: number;  /** e.g. "runs", "GB". */  unit?: string;}export interface SubscriptionCard {  /** e.g. "Visa". */  brand: string;  last4: string;  expMonth: number;  expYear: number;}export interface SubscriptionInvoice {  id: string;  date: Date | string | number;  amount: number;  status: "paid" | "open" | "failed";  /** A PDF or hosted invoice page. */  url?: string;}export interface SubscriptionProps {  plan: SubscriptionPlan;  status: SubscriptionStatus;  periodStart: Date | string | number;  /** When it renews, or ends while canceling. */  periodEnd: Date | string | number;  /** When billing resumes, while paused. */  resumesAt?: Date | string | number;  usage?: SubscriptionUsage[];  card?: SubscriptionCard;  invoices?: SubscriptionInvoice[];  /** Open your provider's portal (or a form) to change the card. */  onUpdateCard?: () => Promise<unknown>;  /** Go to your plans, e.g. the Pricing plans block. */  onChangePlan?: () => void;  /** Pause billing for this many months; shows the offer before cancelling. */  onPause?: (months: number) => Promise<unknown>;  onCancel?: (reason: string, comment: string) => Promise<unknown>;  /** Undo a pause or a cancel that hasn't ended yet. */  onResume?: () => Promise<unknown>;  pauseMonths?: number[];  reasons?: string[];  currency?: string;  locale?: string;  className?: string;}const EASE = "cubic-bezier(0.16,1,0.3,1)";const DAY = 86_400_000;const PAUSE = [1, 2, 3];const REASONS = ["Too expensive", "Not using it enough", "Missing a feature", "Switching to another tool", "Something else"];const NONE: never[] = [];const subscribeNothing = () => () => {};/** False on the server and while hydrating, so dates are written once, in the visitor's own zone. */const useHydrated = () => React.useSyncExternalStore(subscribeNothing, () => true, () => false);const mix = (color: string, amount: number, into = "transparent") => `color-mix(in oklab, ${color} ${amount}%, ${into})`;const LIT: React.CSSProperties = {  backgroundColor: "var(--primary)",  backgroundImage: `radial-gradient(120% 90% at 100% 0%, ${mix("var(--primary-foreground)", 22)} 0%, transparent 55%), linear-gradient(175deg, transparent 40%, ${mix("black", 24)} 100%)`,  boxShadow: `inset 0 1px 0 ${mix("var(--primary-foreground)", 36)}, inset 0 0 0 1px ${mix("var(--primary-foreground)", 12)}`,};const CARD: React.CSSProperties = {  backgroundImage: `linear-gradient(180deg, var(--card), ${mix("var(--card)", 95, "var(--foreground)")})`,  boxShadow: `0 0 0 1px var(--border), inset 0 1px 0 ${mix("var(--background)", 85)}`,};const 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 errorOf = (e: unknown, fallback: string) => (e instanceof Error && e.message ? e.message : fallback);const addMonths = (t: number, m: number) => {  const d = new Date(t);  d.setMonth(d.getMonth() + m);  return d.getTime();};/** The billing period as a ring: how far through it you are, the days left in the middle. */function PeriodRing({ share, days, dim, reduced }: { share: number; days: number | null; dim: boolean; reduced: boolean }) {  const r = 22;  const c = 2 * Math.PI * r;  return (    <span className="relative flex size-[56px] shrink-0 items-center justify-center" style={{ opacity: dim ? 0.55 : 1, transition: "opacity 400ms" }}>      <svg aria-hidden="true" viewBox="0 0 56 56" className="absolute inset-0 size-full -rotate-90">        <circle cx="28" cy="28" r={r} fill="none" stroke={mix("var(--primary-foreground)", 22)} strokeWidth="3" />        <circle cx="28" cy="28" r={r} fill="none" stroke="var(--primary-foreground)" strokeWidth="3" strokeLinecap="round" strokeDasharray={c} style={{ strokeDashoffset: c * (1 - share), transition: reduced ? "none" : `stroke-dashoffset 900ms ${EASE}` }} />        {/* Fine ticks round the ring, one per week of the period. */}        {Array.from({ length: 12 }, (_, i) => (          <line key={i} x1="28" y1="1.5" x2="28" y2="3.5" stroke={mix("var(--primary-foreground)", 35)} strokeWidth="0.8" transform={`rotate(${i * 30} 28 28)`} />        ))}      </svg>      <span className="relative text-center leading-none">        <span className="block text-[16px] font-medium tabular-nums">{days === null ? "–" : <NumberRoll value={days} duration={800} />}</span>        <span className="mt-0.5 block text-[9.5px] uppercase tracking-[0.08em] text-primary-foreground/70">{days === 1 ? "day" : "days"}</span>      </span>    </span>  );}export function Subscription({  plan,  status,  periodStart,  periodEnd,  resumesAt,  usage = NONE,  card,  invoices = NONE,  onUpdateCard,  onChangePlan,  onPause,  onCancel,  onResume,  pauseMonths = PAUSE,  reasons = REASONS,  currency = "USD",  locale,  className = "",}: SubscriptionProps) {  const reduced = useInteractionReducedMotion();  const hydrated = useHydrated();  const baseId = React.useId();  const loc = locale ?? (hydrated ? undefined : "en-US");  const [now, setNow] = React.useState<number | null>(null);  const [ready, setReady] = React.useState(false);  const [step, setStep] = React.useState<0 | 1 | 2>(0);  const [months, setMonths] = React.useState(pauseMonths[Math.min(1, pauseMonths.length - 1)] ?? 1);  const [reason, setReason] = React.useState<string | null>(null);  const [comment, setComment] = React.useState("");  const [act, setAct] = React.useState<{ kind: "pause" | "cancel" | "resume" | "card" | null; status: ActionStatus; error?: string }>({ kind: null, status: "idle" });  const [said, setSaid] = React.useState("");  const faceRefs = React.useRef<(HTMLElement | null)[]>([]);  React.useEffect(() => {    const frame = requestAnimationFrame(() => {      setNow(Date.now());      setReady(true);    });    return () => cancelAnimationFrame(frame);  }, []);  const start = new Date(periodStart).getTime();  const end = new Date(periodEnd).getTime();  const share = now === null ? 0 : Math.min(1, Math.max(0, (now - start) / (end - start)));  // While paused the ring counts down to when billing comes back, not to the old renewal.  const until = status === "paused" && resumesAt !== undefined ? new Date(resumesAt).getTime() : end;  const daysLeft = now === null ? null : Math.max(0, Math.ceil((until - now) / DAY));  const date = (t: Date | string | number) => (hydrated ? new Intl.DateTimeFormat(loc, { month: "short", day: "numeric", year: new Date(t).getFullYear() !== new Date(now ?? end).getFullYear() ? "numeric" : undefined }).format(new Date(t)) : "");  const money = (n: number) => new Intl.NumberFormat(loc, { style: "currency", currency, minimumFractionDigits: n % 1 ? 2 : 0, maximumFractionDigits: 2 }).format(n);  const resumeAt = addMonths(end, months);  const busy = act.status === "pending" || act.status === "success";  const state =    status === "paused"      ? `Paused until ${date(resumesAt ?? end)}`      : status === "canceling"        ? `Ends ${date(end)}`        : status === "trialing"          ? `Trial ends ${date(end)}`          : status === "past_due"            ? "Payment failed"            : `Renews ${date(end)}`;  const go = (s: 0 | 1 | 2) => {    if (busy) return;    setStep(s);    setAct({ kind: null, status: "idle" });    // Focus follows once the surface is mostly turned.    window.setTimeout(() => faceRefs.current[s]?.focus({ preventScroll: true }), reduced ? 0 : FACES_MORPH / 2);  };  const run = async (kind: "pause" | "cancel" | "resume" | "card", fn: (() => Promise<unknown>) | undefined, done: string) => {    if (!fn || busy) return;    setAct({ kind, status: "pending" });    try {      await fn();      setAct({ kind, status: "success" });      setSaid(done);      window.setTimeout(        () => {          if (kind !== "card") setStep(0);          setAct({ kind: null, status: "idle" });        },        kind === "card" ? 1200 : reduced ? 600 : 900,      );    } catch (e) {      setAct({ kind, status: "error", error: errorOf(e, "That didn’t go through. Try again.") });    }  };  const st = (kind: "pause" | "cancel" | "resume" | "card") => (act.kind === kind ? act.status : "idle");  const ghost = `inline-flex h-10 cursor-pointer items-center justify-center rounded-full px-4 text-[13.5px] font-medium ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`;  const ending = status === "paused" || status === "canceling";  return (    <section aria-labelledby={`${baseId}-plan`} className={`w-full max-w-[440px] rounded-[28px] p-2 text-card-foreground ${className}`} style={CARD}>      {/* Its natural width where the parent sizes to its content, without forcing it on narrow screens. */}      <div aria-hidden="true" data-natural-width className="grid h-0" style={{ gridTemplateColumns: "minmax(0, 440px)" }} />      <div className="@container w-full">        {/* The one lit surface: the plan, and the ring of its billing period. */}        <div className="rounded-[22px] p-4 text-primary-foreground" style={{ ...LIT, filter: ending ? "saturate(0.55)" : "none", transition: reduced ? "none" : `filter 500ms ${EASE}` }}>          <div className="flex items-start justify-between gap-3">            <div className="min-w-0">              <p className="text-[12.5px] text-primary-foreground/70">Your plan</p>              <h2 id={`${baseId}-plan`} className="mt-0.5 text-[20px] font-medium leading-tight tracking-[-0.02em]">                {plan.name}                {plan.seats ? <span className="text-primary-foreground/70"> · {plan.seats} seats</span> : null}              </h2>              <p className="mt-2 flex items-baseline gap-1">                <span className="text-[30px] font-medium leading-none tracking-[-0.04em] tabular-nums">                  <NumberRoll value={plan.price} locales={locale} format={{ style: "currency", currency, minimumFractionDigits: plan.price % 1 ? 2 : 0, maximumFractionDigits: 2 }} duration={800} />                </span>                <span className="text-[13px] text-primary-foreground/70">/{plan.interval === "year" ? "yr" : "mo"}</span>              </p>            </div>            <PeriodRing share={ending ? 1 : share} days={daysLeft} dim={ending} reduced={reduced} />          </div>          <p className={`mt-3 inline-flex h-7 items-center gap-1.5 rounded-full px-2.5 text-[12.5px] font-medium ${status === "past_due" ? "text-amber-200" : ""}`} style={{ background: mix("var(--primary-foreground)", 14) }} role="status">            <span aria-hidden="true" className={`size-1.5 rounded-full ${status === "past_due" ? "bg-amber-400" : ending ? "bg-primary-foreground/55" : "bg-emerald-300"}`} />            <TextMorph>{hydrated ? state : " "}</TextMorph>          </p>        </div>        {/* Flat: the details, or the way out, which always offers a pause first. */}        <Faces active={step} reduced={reduced} ready={ready}>          <div            ref={(el) => {              faceRefs.current[0] = el;            }}            tabIndex={-1}            className="px-2 pb-1 pt-1 outline-none"          >            {usage.length > 0 && (              <div className="space-y-2.5 px-1 pt-3">                {usage.map((u) => {                  const pct = Math.min(1, u.used / u.limit);                  const hot = pct >= 0.9;                  return (                    <div key={u.id}>                      <div className="flex items-baseline justify-between gap-3 text-[13px]">                        <span className="text-foreground">{u.label}</span>                        <span className="tabular-nums text-muted-foreground">                          <NumberRoll value={u.used} locales={locale} className={hot ? "font-medium text-amber-600 dark:text-amber-400" : "text-foreground"} /> / {new Intl.NumberFormat(loc).format(u.limit)}                          {u.unit ? ` ${u.unit}` : ""}                        </span>                      </div>                      <div className="mt-1.5 h-1 overflow-hidden rounded-full bg-muted" role="meter" aria-label={u.label} aria-valuemin={0} aria-valuemax={u.limit} aria-valuenow={u.used}>                        <span className={`block h-full origin-left rounded-full ${hot ? "bg-amber-500" : "bg-primary"}`} style={{ transform: `scaleX(${ready ? pct : 0})`, transition: reduced ? "none" : `transform 900ms ${EASE}` }} />                      </div>                    </div>                  );                })}              </div>            )}            {card && (              <div className="mt-3 flex h-12 items-center gap-3 border-t border-border px-1 pt-3">                <span aria-hidden="true" className="flex h-7 w-10 shrink-0 items-center justify-center rounded-[6px] border border-border bg-background font-mono text-[9px] font-medium uppercase tracking-[0.06em] text-foreground">                  {card.brand.slice(0, 4)}                </span>                <span className="min-w-0 flex-1 text-[13px]">                  <span className="block truncate text-foreground">                    {card.brand} •••• {card.last4}                  </span>                  <span className="block text-[12px] text-muted-foreground">                    Expires {String(card.expMonth).padStart(2, "0")}/{String(card.expYear).slice(-2)}                  </span>                </span>                {onUpdateCard && (                  <StatusButton                    variant="ghost"                    size="sm"                    status={st("card")}                    labels={{ idle: "Update", pending: "Opening", success: "Opened", error: "Try again" }}                    onClick={() => void run("card", onUpdateCard, "Opened the payment page.")}                    className="-mr-1 shrink-0 text-foreground"                  />                )}              </div>            )}            {act.kind === "card" && act.error && (              <p role="alert" className="px-1 pt-1 text-[12px] text-red-600 dark:text-red-400">                {act.error}              </p>            )}            {invoices.length > 0 && (              <div className="mt-3 border-t border-border px-1 pt-2.5">                <p className="text-[12px] text-muted-foreground">Invoices</p>                <ul className="mt-1">                  {invoices.slice(0, 3).map((inv) => (                    <li key={inv.id} className="flex h-9 items-center gap-3 text-[13px]">                      <span className="min-w-0 flex-1 text-foreground">{date(inv.date)}</span>                      <span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${inv.status === "paid" ? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400" : inv.status === "open" ? "bg-amber-500/10 text-amber-700 dark:text-amber-400" : "bg-red-500/10 text-red-600 dark:text-red-400"}`}>                        {inv.status === "paid" ? "Paid" : inv.status === "open" ? "Open" : "Failed"}                      </span>                      <span className="w-[72px] text-right tabular-nums text-foreground">{money(inv.amount)}</span>                      {inv.url ? (                        <a href={inv.url} target="_blank" rel="noreferrer" aria-label={`Download the invoice of ${date(inv.date)}`} className={`-mr-1 flex size-7 items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground ${INTERACTION_FOCUS}`}>                          <ArrowDownToLine aria-hidden="true" className="size-3.5" />                        </a>                      ) : (                        <span className="w-6" />                      )}                    </li>                  ))}                </ul>              </div>            )}            <div className="mt-2.5 flex gap-2 border-t border-border pt-3">              {ending && onResume ? (                <StatusButton                  status={st("resume")}                  labels={{ idle: status === "paused" ? "Resume now" : "Keep my plan", pending: "Resuming", success: "Resumed", error: "Try again" }}                  onClick={() => void run("resume", onResume, "Your plan is back on.")}                  className="h-10 flex-1 text-[13.5px]"                  style={st("resume") === "error" ? undefined : ACTION}                />              ) : (                onChangePlan && (                  <button type="button" onClick={onChangePlan} className={`${ghost} flex-1 text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent`}>                    Change plan                  </button>                )              )}              {!ending && (onPause || onCancel) && (                <button type="button" onClick={() => go(onPause ? 1 : 2)} className={`${ghost} text-muted-foreground hover:bg-accent hover:text-foreground`}>                  Cancel plan                </button>              )}            </div>            {act.kind === "resume" && act.error && (              <p role="alert" className="px-1 pt-1.5 text-[12px] text-red-600 dark:text-red-400">                {act.error}              </p>            )}          </div>          {/* Before you go: a pause, with the date it all comes back. */}          <div            ref={(el) => {              faceRefs.current[1] = el;            }}            tabIndex={-1}            role="group"            aria-labelledby={`${baseId}-pause`}            className="px-3 pb-1.5 pt-3.5 outline-none"          >            <h3 id={`${baseId}-pause`} className="text-[16px] font-medium tracking-[-0.01em]">              Pause instead?            </h3>            <p className="mt-1 text-[13px] leading-relaxed text-muted-foreground">Keep everything as it is and pick up where you left off. You won’t be charged while it’s paused.</p>            <SegmentedControl              label="Pause for"              block              className="mt-3"              value={String(months)}              onValueChange={(v) => setMonths(Number(v))}              options={pauseMonths.map((m) => ({ value: String(m), label: m === 1 ? "1 month" : `${m} months` }))}            />            <p className="mt-2.5 text-[13px] text-muted-foreground">              Billing resumes on <TextMorph className="font-medium text-foreground">{hydrated ? date(resumeAt) : " "}</TextMorph>            </p>            <StatusButton              status={st("pause")}              labels={{ idle: months === 1 ? "Pause for 1 month" : `Pause for ${months} months`, pending: "Pausing", success: "Paused", error: "Try again" }}              onClick={() => void run("pause", onPause && (() => onPause(months)), `Paused for ${months} months.`)}              className="mt-3.5 h-11 w-full text-[14.5px]"              style={st("pause") === "error" ? undefined : ACTION}            />            {act.kind === "pause" && act.error && (              <p role="alert" className="mt-1.5 text-center text-[12px] text-red-600 dark:text-red-400">                {act.error}              </p>            )}            <div className="mt-1.5 flex justify-between">              <button type="button" onClick={() => go(0)} disabled={busy} className={`${ghost} -ml-2 h-9 px-3 text-muted-foreground hover:bg-accent hover:text-foreground`}>                Keep my plan              </button>              {onCancel && (                <button type="button" onClick={() => go(2)} disabled={busy} className={`${ghost} -mr-2 h-9 px-3 text-muted-foreground hover:bg-accent hover:text-foreground`}>                  No, cancel it                </button>              )}            </div>          </div>          {/* Cancel: a reason, then the end date. */}          <div            ref={(el) => {              faceRefs.current[2] = el;            }}            tabIndex={-1}            role="group"            aria-labelledby={`${baseId}-cancel`}            className="px-3 pb-1.5 pt-3 outline-none"          >            <div className="-ml-1.5 flex items-center gap-1">              <button type="button" aria-label="Back" disabled={busy} onClick={() => go(onPause ? 1 : 0)} className={`flex size-8 cursor-pointer items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`}>                <ChevronLeft aria-hidden="true" className="size-[18px]" />              </button>              <h3 id={`${baseId}-cancel`} className="text-[16px] font-medium tracking-[-0.01em]">                Why are you leaving?              </h3>            </div>            <div role="radiogroup" aria-labelledby={`${baseId}-cancel`} className="mt-2.5 flex flex-wrap gap-1.5">              {reasons.map((r) => {                const on = r === reason;                return (                  <button                    key={r}                    type="button"                    role="radio"                    aria-checked={on}                    disabled={busy}                    onClick={() => setReason(r)}                    className={`h-8 cursor-pointer rounded-full px-3 text-[12.5px] ${INTERACTION_PRESS} ${INTERACTION_FOCUS} ${on ? "bg-primary/[0.08] font-medium text-primary shadow-[inset_0_0_0_1.5px_var(--primary)]" : "text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent"}`}                    style={{ transition: reduced ? "none" : `box-shadow 260ms ${EASE_THROW}, background-color 200ms` }}                  >                    {r}                  </button>                );              })}            </div>            <Textarea aria-label="Anything we could have done better?" placeholder="Anything we could have done better? (optional)" value={comment} onValueChange={setComment} disabled={busy} minRows={2} maxRows={4} maxLength={500} showCount={false} className="mt-2.5" />            <StatusButton              variant="outline"              status={st("cancel")}              labels={{ idle: "Cancel subscription", pending: "Cancelling", success: "Cancelled", error: "Try again" }}              disabled={!reason && st("cancel") === "idle"}              onClick={() => void run("cancel", onCancel && (() => onCancel(reason ?? "", comment.trim())), `Cancelled. ${plan.name} stays on until ${date(end)}.`)}              className="mt-2.5 h-11 w-full text-[14.5px] text-red-600 disabled:opacity-45 dark:text-red-400"            />            <p role={act.kind === "cancel" && act.error ? "alert" : undefined} className={`mt-1.5 text-center text-[12px] ${act.kind === "cancel" && act.error ? "text-red-600 dark:text-red-400" : "text-muted-foreground"}`}>              {act.kind === "cancel" && act.error ? act.error : hydrated ? `You keep ${plan.name} until ${date(end)}.` : " "}            </p>          </div>        </Faces>        <span className="sr-only" role="status">          {said}        </span>      </div>    </section>  );}export default Subscription;
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/field.tsx
"use client";import * as React from "react";import { EASE_OUT, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * FIELD: the column every text control sits in * *   label    above, with an optional aside on the right (a count) *   hint     under the control while all is well *   error    takes the hint's place: the hint folds away, the *            error rises in out of a blur, the control shakes once * * Shared by input, textarea, password, search and select so the * whole family has one surface, one focus ring and one error. * ───────────────────────────────────────────────────────── */export interface FieldProps {  id: string;  label?: React.ReactNode;  /** Right side of the label row, e.g. a character count. */  aside?: React.ReactNode;  hint?: React.ReactNode;  error?: React.ReactNode;  required?: boolean;  className?: string;  children: React.ReactNode;}export type FieldSize = "sm" | "md";/** The surface of a text control: a hairline that darkens on hover and turns to the ring on focus. */export function fieldSurface({ invalid, disabled, round = true }: { invalid?: boolean; disabled?: boolean; round?: boolean }) {  const ring = invalid    ? "shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_55%,transparent)] focus-within:shadow-[inset_0_0_0_1px_var(--color-red-500),0_0_0_3px_color-mix(in_oklab,var(--color-red-500)_14%,transparent)]"    : "shadow-[inset_0_0_0_1px_var(--border)] [&:not(:focus-within)]:hover:shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_24%,var(--border))] focus-within:shadow-[inset_0_0_0_1px_var(--ring),0_0_0_3px_color-mix(in_oklab,var(--ring)_16%,transparent)]";  return `relative flex w-full items-center bg-background text-foreground transition-[box-shadow,background-color,opacity] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] ${round ? "rounded-full" : "rounded-[14px]"} ${ring} ${    disabled ? "pointer-events-none opacity-50" : ""  }`;}/** ids for the control's label, hint and error. */export function useFieldIds(id?: string) {  const auto = React.useId();  const base = id ?? auto;  return { id: base, hint: `${base}-hint`, error: `${base}-error` };}/** One short shake whenever `on` turns true (an error arrives). */export function useShake<T extends HTMLElement>(on: boolean) {  const ref = React.useRef<T>(null);  const reduced = useInteractionReducedMotion();  React.useEffect(() => {    if (!on || reduced) return;    ref.current?.animate(      [{ transform: "none" }, { transform: "translateX(-4px)" }, { transform: "translateX(4px)" }, { transform: "translateX(-2px)" }, { transform: "none" }],      { duration: 380, easing: "ease-out" },    );  }, [on, reduced]);  return ref;}/** Sets a native input's value so React's onChange still fires (clear buttons, pickers). */export function setNativeValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) {  const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;  Object.getOwnPropertyDescriptor(proto, "value")?.set?.call(el, value);  el.dispatchEvent(new Event("input", { bubbles: true }));}/** A line under the control that folds open and closed; keeps its last words while closing. */function Line({ show, children, id, tone, reduced }: { show: boolean; children: React.ReactNode; id: string; tone: string; reduced: boolean }) {  const [kept, setKept] = React.useState(children);  if (show && children !== kept) setKept(children);  return (    <div      className="grid"      style={{ gridTemplateRows: show ? "1fr" : "0fr", transition: reduced ? "none" : `grid-template-rows 320ms ${EASE_OUT}` }}    >      <div className="min-h-0 overflow-hidden">        <p          id={id}          className={`pt-1.5 pl-3 text-[12.5px] leading-snug ${tone}`}          style={{            opacity: show ? 1 : 0,            filter: show ? "blur(0px)" : "blur(4px)",            transform: show ? "none" : "translateY(-4px)",            transition: reduced ? "none" : show ? `opacity 280ms ${EASE_OUT} 60ms, filter 280ms ${EASE_OUT} 60ms, transform 380ms ${EASE_OUT}` : `opacity 140ms ${EASE_OUT}, filter 140ms ${EASE_OUT}, transform 200ms ${EASE_OUT}`,          }}        >          {show ? children : kept}        </p>      </div>    </div>  );}export function Field({ id, label, aside, hint, error, required, className = "", children }: FieldProps) {  const reduced = useInteractionReducedMotion();  const ids = { hint: `${id}-hint`, error: `${id}-error` };  return (    <div className={`flex w-full flex-col ${className}`}>      {(label || aside) && (        <div className="flex items-baseline justify-between gap-3 px-3 pb-1.5">          {label && (            <label htmlFor={id} className="text-[13px] font-medium text-foreground">              {label}              {required && (                <span aria-hidden="true" className="ml-0.5 text-muted-foreground">                  *                </span>              )}            </label>          )}          {aside && <span className="text-[12px] tabular-nums text-muted-foreground">{aside}</span>}        </div>      )}      {children}      <Line show={!error && !!hint} id={ids.hint} tone="text-muted-foreground" reduced={reduced}>        {hint}      </Line>      <Line show={!!error} id={ids.error} tone="text-red-600 dark:text-red-400" reduced={reduced}>        {error}      </Line>    </div>  );}export default Field;
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/subscription.json