caliper uidocs

Pricing block

Block

Pricing worked out from the buyer's own numbers: enter seats and each plan shows its total and why it fits.

Installs components/ui/pricing-block.tsxDepends on lucide-reactMotion shared motion tokens
pnpm dlx shadcn@latest add https://caliperui.site/r/pricing-block.json

Pricing

Priced for your team

Move the slider to your headcount. The plan that fits is marked, with the reason.

  • Starter

    —Not available for 14 seats

    Not available above 5 seats
    • 2 bank connections
    • Monthly close
    • Email support
  • TeamFor you

    /mo$448 per month

    14 seats × $32
    • Unlimited connections
    • Daily matching
    • Audit trail export
  • Scale

    /mo$672 per month

    14 seats × $48
    • Multi-entity
    • SSO and SCIM
    • Named accountant

14 seats is more than Starter allows, so Team fits.

Prices in USD, before tax.

When to use #

Use it for

  • Per-seat, per-unit or usage pricing.
  • Buyers ask which plan fits before they ask what it costs.

Skip it when

  • One flat price; state it in the call to action.
  • Quote-only enterprise sales; use a contact band.

Installation #

terminal
pnpm dlx shadcn@latest add https://caliperui.site/r/pricing-block.json

Adds its 7 files to components/ui/, each with its own stylesheet, and installs their packages. No account needed. The first part also adds Caliper UI’s tokens; import them once as in Installation.

Usage #

This is the code behind the preview above.

example.tsx
import { type ReactNode } from "react"
import { FeatureRowsBlock } from "@/components/ui/feature-rows-block"
import { PricingBlock } from "@/components/ui/pricing-block"
import { TeamBlock } from "@/components/ui/team-block"

function Frame({ children }: { children: ReactNode }) {
  return <div className="w-full overflow-hidden rounded-[14px] shadow-[0_0_0_1px_var(--border)]">{children}</div>;
}

function Ledger() {
  const rows = [
    ["Mercury", "Stripe payout · INV-2291", "$18,240.00", "Matched", "rule: payouts"],
    ["AWS", "Invoice 0923", "$4,112.37", "Matched", "vendor history"],
    ["Gusto", "Payroll · 31 Oct", "$61,904.12", "Review", "amount differs by $12.40"],
    ["Figma", "Annual · Org plan", "$2,880.00", "Matched", "rule: SaaS"],
    ["Brex", "Card settlement", "$9,377.80", "Matched", "statement"],
  ];
  return (
    <div className="grid gap-4 text-[13px]">
      <div className="grid gap-2">
        <span className="text-[var(--k-muted)]">October close</span>
        <span data-slot="block-title" data-size="3" className="tabular-nums">412 of 418 matched</span>
        <div className="h-1.5 w-full max-w-[260px] overflow-hidden rounded-full bg-[var(--k-sunken)]" aria-hidden="true"><i className="block h-full w-[98.5%] rounded-full bg-[var(--k-hi)]" /></div>
      </div>
      <div className="grid">
        {rows.map(([vendor, memo, amount, state, why]) => (
          <div key={memo} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 gap-y-0.5 border-t border-[var(--k-line)] py-2.5 tabular-nums @[560px]:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto_88px]">
            <span className="truncate font-medium">{vendor} <span className="font-normal text-[var(--k-muted)]">· {memo}</span></span>
            <span className="truncate text-[var(--k-muted)] @max-[559px]:hidden">{why}</span>
            <span className="text-right">{amount}</span>
            <span className={`justify-self-end rounded-full px-2 py-0.5 text-xs @max-[559px]:hidden ${state === "Review" ? "bg-[var(--k-hi-soft)] text-[var(--k-hi)]" : "text-[var(--k-muted)]"}`}>{state}</span>
          </div>
        ))}
      </div>
    </div>
  );
}

function MatchFeed() {
  const lines = [["09:12", "Stripe payout", "$18,240.00", "rule: payouts"], ["09:12", "AWS · 0923", "$4,112.37", "vendor history"], ["13:04", "Figma · Annual", "$2,880.00", "rule: SaaS"], ["17:30", "Brex settlement", "$9,377.80", "statement"]];
  return (
    <ul className="grid max-w-[460px] gap-2 text-[13px]">
      {lines.map(([time, name, amount, why], index) => (
        <li key={name} className="flex items-center gap-3 rounded-[12px] bg-[var(--k-surface)] px-3.5 py-2.5 tabular-nums shadow-[0_0_0_1px_var(--k-line)]" style={{ opacity: 1 - index * 0.14 }}>
          <span className="font-mono text-[11px] text-[var(--k-muted)]">{time}</span>
          <span className="min-w-0 flex-1 truncate font-medium">{name}</span>
          <span className="text-[var(--k-muted)] @max-[480px]:hidden">{why}</span>
          <span>{amount}</span>
        </li>
      ))}
    </ul>
  );
}

const features: Parameters<typeof FeatureRowsBlock>[0] = { eyebrow: "Why teams switch", title: "What changes at month end", lead: "Three things stop being a scramble the first month you run Halden.", features: [
    { title: "Lines match as they land", body: "Bank feeds reconcile against the ledger every four hours, with the rule that matched each line.", metric: { value: "97%", label: "matched without a touch" }, media: <MatchFeed /> },
    { title: "Exceptions come with context", body: "Anything unmatched arrives with the three likeliest invoices and who approved them.", metric: { value: "6", label: "lines left in October" } },
    { title: "An audit trail auditors ask for", body: "Every match, edit and approval exports as one file your auditors can sample.", metric: { value: "1 file", label: "instead of 40 spreadsheets" } },
  ] };

const plans = {
  paper: [
    { name: "Starter", price: 19, limit: 5, features: ["2 bank connections", "Monthly close", "Email support"], action: { label: "Start Starter", href: "#" } },
    { name: "Team", price: 32, limit: 25, features: ["Unlimited connections", "Daily matching", "Audit trail export"], action: { label: "Start Team", href: "#" } },
    { name: "Scale", price: 48, limit: 100, features: ["Multi-entity", "SSO and SCIM", "Named accountant"], action: { label: "Talk to us", href: "#" } },
  ],
  foundry: [
    { name: "Yard", price: 40, features: [], action: { label: "Book a yard walk", href: "#" } },
    { name: "Network", price: 32, features: [], action: { label: "Price a network", href: "#" } },
    { name: "Enterprise", features: [], action: { label: "Call us", href: "#" } },
  ],
  meadow: [
    { name: "Solo", price: 4, description: "Up to 5 people. Payroll, payslips and HMRC filing.", features: [], action: { label: "Start Solo", href: "#" } },
    { name: "Team", price: 6, description: "Up to 50 people. Adds pensions, holidays and expenses.", features: [], action: { label: "Start Team", href: "#" } },
    { name: "Plus", price: 8, description: "Adds a named specialist and same-day changes.", features: [], action: { label: "Start Plus", href: "#" } },
  ],
  signal: [
    { name: "Start free", price: 0, features: [], action: { label: "npx northwind init", href: "#" } },
    { name: "Talk to an engineer", features: [], action: { label: "Talk to an engineer", href: "#" } },
  ],
};

const pricing: Parameters<typeof PricingBlock>[0] = { eyebrow: "Pricing", title: "Priced for your team", lead: "Move the slider to your headcount. The plan that fits is marked, with the reason.", unit: "seat", plans: plans.paper, footnote: "Prices in USD, before tax." };

export function PricingBlockDemo() {
  return <Frame><PricingBlock {...pricing} /></Frame>;
}

const team: Parameters<typeof TeamBlock>[0] = { eyebrow: "Team", title: "The people behind your close", lead: "Write to any of us directly. A person answers.", footnote: "14 people in Lisbon and London. We are hiring a second support lead.", people: [
    { name: "Priya Raman", role: "Co-founder", owns: "owns the matching engine", contact: { label: "priya@halden.app", href: "mailto:priya@halden.app" } },
    { name: "Rafael Abreu", role: "Head of support", owns: "answers in under 2h", contact: { label: "help@halden.app", href: "mailto:help@halden.app" } },
    { name: "Kiara Murayama", role: "Security", owns: "SOC 2, DPAs, pen tests", contact: { label: "security@halden.app", href: "mailto:security@halden.app" } },
    { name: "Tomás Ferreira", role: "Accountant in residence", owns: "CPA", contact: { label: "tomas@halden.app", href: "mailto:tomas@halden.app" } },
  ] };

function Exceptions() {
  return (
    <div className="grid gap-3 text-[13px]">
      <div className="flex items-baseline justify-between gap-3"><span data-slot="block-title" data-size="3">6 lines need you</span><span className="text-[var(--k-muted)]">sorted by amount</span></div>
      {[["Gusto · Payroll", "$61,904.12", "Ledger says $61,891.72", "Employer NI adjustment, Oct"], ["Stripe · Refund", "$1,280.00", "No matching credit note", "Refund for INV-2244, 2 Oct"]].map(([name, amount, issue, guess]) => (
        <div key={name} className="grid gap-2 rounded-[12px] p-4 shadow-[0_0_0_1px_var(--k-line)]">
          <div className="flex justify-between gap-3 tabular-nums"><b className="font-medium">{name}</b><span>{amount}</span></div>
          <span className="text-[var(--k-muted)]">{issue}</span>
          <span className="flex flex-wrap items-center gap-2"><span className="rounded-full bg-[var(--k-hi-soft)] px-2 py-0.5 text-xs text-[var(--k-hi)]">Likely</span>{guess}</span>
        </div>
      ))}
    </div>
  );
}

API reference #

PricingBlock

component
PropTypeDefaultDescription
eyebrowstring—
title *ReactNode—
leadstring—
plans *readonly BlockPlan[]—
unitstring"seat"
units{ min: number; max: number; initial: number; label?: string | undefined; }{ min: 1, max: 40, initial: 14 }
currencystring"$"
yearlyDiscountnumber1 / 6Share taken off when billed yearly; 1/6 means two months free.
footnoteReactNode—
billing"per-unit" | "once""per-unit"
compareBlockPlanCompare—
compareTitlestring"Compare plans"
headingLevel1 | 22
classNamestring—

Accessibility #

It exposes region semantics, with aria-label, aria-hidden, aria-valuetext and aria-live kept in step with state. Changes that matter are announced through a live region.

No custom key handling; Tab and the native activation keys do the work.

Motion #

Transitions use the shared duration and easing tokens. Under prefers-reduced-motion those tokens collapse to instant and travel distances go to zero, so state changes still happen without movement.