Skip to content

KpiCard

Metric + delta + optional sparkline.
Implemented
Status
Implemented
Family
2 of 13
Registry
@lopifi/kpi-card
npx shadcn@latest add @lopifi/theme @lopifi/kpi-card

The component, rendering

Real data, the shipped component. Switch the theme to check the claims the spec makes below.

Preview

The four-layer spec

Label · figure · delta · optional sparkline. The dashboard atom.

  • Baselines align across a KpiRow. Use grid with auto-rows-fr and 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.
  • polarity required, 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.

Files

components/ledger/kpi-card.tsx

Lopifi dependencies · 11
cardcndeltafigureformat-moneymoneymoney.typespercentsparklinetooltipuse-media-query
npm packages
@base-ui-components/reactclass-variance-authorityclsxtailwind-merge
Registry entry
/r/kpi-card.json

The whole component

No compiled bundle, no black box. This is the file the registry copies, and the file you will edit.

components/ledger/kpi-card.tsx
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

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.

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.

One address, no list rental, unsubscribe in one click.

Works in your QuickBooks Online or Xero file

Get your books closed every month

Lopifi is building AI bookkeeping that keeps your books closed on US GAAP as transactions happen, with a CPA review every quarter, for $200 a month. We’re not open yet.

$200/month when we open. No card now.