Feature rows block
BlockFeatures as numbered rows, each with a product fragment or one figure that proves it.
pnpm dlx shadcn@latest add https://caliperui.site/r/feature-rows-block.jsonWhy teams switch
What changes at month end
Three things stop being a scramble the first month you run Halden.
- 01
Lines match as they land
Bank feeds reconcile against the ledger every four hours, with the rule that matched each line.
- 09:12Stripe payoutrule: payouts$18,240.00
- 09:12AWS · 0923vendor history$4,112.37
- 13:04Figma · Annualrule: SaaS$2,880.00
- 17:30Brex settlementstatement$9,377.80
97%matched without a touch
- 02
Exceptions come with context
Anything unmatched arrives with the three likeliest invoices and who approved them.
6lines left in October
- 03
An audit trail auditors ask for
Every match, edit and approval exports as one file your auditors can sample.
1 fileinstead of 40 spreadsheets
When to use #
Use it for
- Three to six capabilities that each deserve a sentence.
- Each feature has a real fragment or measurable result to show.
Skip it when
- A long checklist; use the pricing comparison.
- Features without proof; cut them.
Installation #
pnpm dlx shadcn@latest add https://caliperui.site/r/feature-rows-block.json
Adds its 4 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.
import { type ReactNode } from "react" import { FeatureRowsBlock } from "@/components/ui/feature-rows-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" } }, ] }; export function FeatureBlockDemo() { return <Frame><FeatureRowsBlock {...features} /></Frame>; } 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 #
FeatureRowsBlock
component
Accessibility #
It is built on native elements, so roles and focus come from the browser.
No custom key handling; Tab and the native activation keys do the work.
Motion #
It declares no animation in its component source. Any transition comes from its stylesheet, which uses the duration tokens that collapse to instant under prefers-reduced-motion.