KpiCard
- Status
- Implemented
- Family
- 2 of 13
- Registry
- @lopifi/kpi-card
npx shadcn@latest add @lopifi/theme @lopifi/kpi-cardThe component, rendering
Real data, the shipped component. Switch the theme to check the claims the spec makes below.
The four-layer spec
Label · figure · delta · optional sparkline. The dashboard atom.
- Baselines align across a
KpiRow. Usegridwithauto-rows-frand a shared figure row, or two cards with different label lengths will have their figures at different heights — the most visible alignment failure on any dashboard. - Display family forbidden here. Figures align across the row.
polarityrequired, no default. Forcing the decision is the point.- The comparison basis is always shown:
vs. Aug, never a bare+5.3%.
Loading: skeleton with the figure block at the exact figure width, or the card jumps when data lands.
Lifted verbatim from Cards. The doc and this page are the same paragraphs, so they cannot disagree.
What lands in your repository
One component is never one file. Everything below is copied into your project, where you own it.
The whole component
No compiled bundle, no black box. This is the file the registry copies, and the file you will edit.
import * as React from "react";
import { cn } from "@/lib/ledger/cn";
import type { MoneyValue, PercentChange, Polarity } from "@/lib/ledger/money.types";
import { Card } from "@/components/ledger/card";
import { Delta } from "@/components/ledger/delta";
import { Figure } from "@/components/ledger/figure";
import { Sparkline } from "@/components/ledger/sparkline";
export interface KpiCardProps extends React.ComponentPropsWithoutRef<"div"> {
/** Always shown. Never a bare "+5.3%". */
basis: string;
data?: number[];
delta?: MoneyValue | null;
footnote?: string;
label: string;
percent?: PercentChange;
/** Required, no default. Forcing the decision is the point. */
polarity: Polarity;
value: MoneyValue | null;
}
/**
* The dashboard atom. The display family is forbidden here — these figures
* align across a `KpiRow`, and only the figure face holds a column.
*
* Baselines align across the row via `auto-rows-fr` plus a fixed label row,
* or two cards with different label lengths put their figures at different
* heights — the most visible alignment failure on any dashboard.
*/
export const KpiCard = React.forwardRef<HTMLDivElement, KpiCardProps>(
({ className, label, value, delta, percent, polarity, basis, data, footnote, ...props }, ref) => (
<Card className={cn("flex h-full flex-col", className)} ref={ref} {...props}>
<span className="line-clamp-2 min-h-8 text-label font-medium text-text-secondary">{label}</span>
<div className="mt-2">
{/* family is not offered: the parent forces Inter. */}
<Figure size="lg" value={value} />
</div>
{/* The basis gets its own line rather than trailing the delta. Inline, it
wrapped on some cards in a row and not others, which put the sparklines
at different heights — the alignment failure this component exists to
avoid. On its own line it costs one row on every card equally. */}
<div className="mt-2">
<Delta percent={percent} polarity={polarity} size="xs" value={delta ?? null} />
<p className="mt-0.5 truncate text-caption text-text-tertiary">{basis}</p>
</div>
{data ? (
<div className="mt-auto pt-4">
<Sparkline data={data} height={24} polarity={polarity} />
</div>
) : null}
{footnote ? <p className="mt-2 text-caption text-text-tertiary">{footnote}</p> : null}
</Card>
),
);
KpiCard.displayName = "KpiCard";
export interface KpiRowProps extends React.ComponentPropsWithoutRef<"div"> {
columns?: 2 | 3 | 4;
}
/** 2–4 cards on a shared figure baseline. Equal-height rows, one line of CSS. */
export const KpiRow = React.forwardRef<HTMLDivElement, KpiRowProps>(
({ className, columns = 4, ...props }, ref) => (
<div
className={cn(
"grid auto-rows-fr gap-4",
columns === 2 && "grid-cols-1 sm:grid-cols-2",
columns === 3 && "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
columns === 4 && "grid-cols-1 sm:grid-cols-2 xl:grid-cols-4",
className,
)}
ref={ref}
{...props}
/>
),
);
KpiRow.displayName = "KpiRow";
export interface StatCardProps extends React.ComponentPropsWithoutRef<"div"> {
label: string;
value: MoneyValue | null;
}
/** The minimal unit: label + value, 16px inset. No delta, no media. */
export const StatCard = React.forwardRef<HTMLDivElement, StatCardProps>(
({ className, label, value, ...props }, ref) => (
<Card className={className} inset="compact" ref={ref} {...props}>
<span className="block truncate text-caption text-text-secondary">{label}</span>
<Figure className="mt-1" size="md" value={value} />
</Card>
),
);
StatCard.displayName = "StatCard";
Also in Cards
- BalanceCardThe hero: one trusted number.
- AccountCardBank account with available vs. current.
- PaymentMethodCardThe physical card object. 3D flip, pointer tilt.
- RunwayCardMonths of runway with a burn line.
- InvoiceCardInvoice summary with aging state.
- SubscriptionCardRecurring charge with next-billing.
- AnomalyCardA flagged deviation with its explanation.
- GoalCardTarget vs. actual with a progress rule.
- StatCardThe minimal label+value card.Spec
See also
A component is specified in one place, defined in another, and running in a third. All three, from here.
Elsewhere in the system
Where KpiCard sits in the rest of the system — the live section it renders in, the calculators that compute what it displays, and the ledger payloads it is fed from.
- KpiCard, runningThe cards showcase — live, with theme, density, greyscale and reduced-motion switches.Live
- Margin & markupThey are not the same number, and confusing them is how a price list quietly loses money.Tool
- CardsAll cards: 24px radius, 24px inset, --surface on --canvas, 1px --border, no resting shadow. Specs for 10 React components: BalanceCard, KpiCard, AccountCard, PaymentMethodCard and 6 more.Doc
Components are copied into your repository and meant to be edited — rounding policy, fiscal calendar and compliance copy differ per company.
Told when Cards changes
Components are copied into your repo, so a fix here does not reach you on its own. One email when a component in this family changes in a way that needs a migration — and nothing when it does not.