v1.0

KpiTile

Preview

Basic

Loading…

Preview

Code

ts
import KpiTile from '@/components/data/KpiTile';

src/components/data/KpiTile.tsx

AI prompt

text
Build a KPI tile (dashboard stat card) component in React + TypeScript + Tailwind CSS.

## Look
- A card (the house panel) with no padding of its own, `overflow-hidden h-full flex flex-col`, so every tile in a row stretches to the same height.
- Header row: `px-4 py-2.5`, hairline bottom border (slate-100 / dark slate-800). An icon chip (`rounded-lg p-1.5`, holding a 16px icon) then the title in 12px semibold slate-600 / dark slate-300, truncating.
- Icon chip tones — a 10% tint of the 500 step with 600 / dark 400 icon colour: indigo `bg-indigo-500/10 text-indigo-600 dark:text-indigo-400`, and the same pattern for amber, violet and emerald.
- Body: `flex flex-1 min-h-[3.25rem] items-end justify-between gap-2 px-4 py-3`. The fixed min-height keeps tiles equal whether the corner holds a two-line delta or a one-line subtitle.
- Bottom-left: the value, `text-2xl font-bold font-mono tabular-nums tracking-tight`, slate-900 / dark white, truncating.
- Bottom-right, one of:
  - `subtitle`: 10px right-aligned slate-400 / dark slate-500 text, at most 45% of the width.
  - otherwise a delta (only when `delta` is passed at all): two stacked right-aligned lines — "vs prev 7d" in 10px slate-400, then the change in 12px semibold tabular nums with a 12px arrow icon.

## Behaviour
- The delta is a percentage. Show `Math.abs(value).toFixed(1)%` with an up-right arrow for ≥ 0 or a down-right arrow for < 0.
- Colour means good or bad, not up or down: good is emerald-600 / dark emerald-400, bad is rose-600 / dark rose-400. `positiveIsGood={false}` flips it (for costs and churn).
- Under 0.05% in either direction counts as flat: a minus icon, slate-400.
- `null` or a non-finite delta: the "vs prev 7d" caption over an em dash in slate-400.
- The value arrives as a string that is already formatted. The tile does no number formatting.

## API
`title: string`, `value: string`, `icon: ReactNode`, `tone?: 'indigo' | 'amber' | 'violet' | 'emerald'` (default indigo), `delta?: number | null`, `positiveIsGood?: boolean` (default true), `subtitle?: string` (takes the place of the delta). Also export the `Delta` piece on its own.

## Demo
A responsive row of four (`grid gap-3 sm:grid-cols-2 lg:grid-cols-4`): Members "1,204" (indigo, Users icon, +4.2), Active projects "86" (emerald, Zap, +12), Spend "$551,650" (amber, Coins, −8.1), and Net margin "25.2%" (violet, TrendingUp, subtitle "vs previous period").

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
/* Origin: bonus-adjustment (96S2), verbatim. */
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
import { cn } from '@/lib/cn';

/**
 * Card anatomy, borrowed from marketing-stats: header row with an icon chip and
 * a title, a hairline divider, then the big tabular value bottom-left with a
 * small stacked secondary stat bottom-right.
 *
 * The fixed body min-height is what keeps every tile in the row the same height
 * whether its corner slot holds a two-line delta or a one-line subtitle.
 */
export function Delta({ value, positiveIsGood = true }: { value: number | null; positiveIsGood?: boolean }) {
  if (value === null || !Number.isFinite(value)) {
    return (
      <span className="flex flex-col items-end leading-tight">
        <span className="text-[10px] text-slate-400 dark:text-slate-500">vs prev 7d</span>
        <span className="text-xs font-medium text-slate-400 dark:text-slate-500">—</span>
      </span>
    );
  }

  const up = value >= 0;
  const flat = Math.abs(value) < 0.05;
  const good = positiveIsGood ? up : !up;
  const Icon = flat ? Minus : up ? ArrowUpRight : ArrowDownRight;

  return (
    <span className="flex flex-col items-end leading-tight">
      <span className="text-[10px] text-slate-400 dark:text-slate-500">vs prev 7d</span>
      <span
        className={cn(
          'inline-flex items-center gap-0.5 text-xs font-semibold tabular-nums',
          flat
            ? 'text-slate-400 dark:text-slate-500'
            : good
              ? 'text-emerald-600 dark:text-emerald-400'
              : 'text-rose-600 dark:text-rose-400',
        )}
      >
        <Icon className="w-3 h-3" />
        {Math.abs(value).toFixed(1)}%
      </span>
    </span>
  );
}

export default function KpiTile({
  title,
  value,
  icon,
  tone = 'indigo',
  delta,
  positiveIsGood = true,
  subtitle,
}: {
  title: string;
  value: string;
  icon: React.ReactNode;
  tone?: 'indigo' | 'amber' | 'violet' | 'emerald';
  delta?: number | null;
  positiveIsGood?: boolean;
  subtitle?: string;
}) {
  const chip = {
    indigo: 'bg-indigo-500/10 text-indigo-600 dark:text-indigo-400',
    amber: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
    violet: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
    emerald: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
  }[tone];

  return (
    <div className="panel p-0 overflow-hidden h-full flex flex-col">
      <div className="flex items-center gap-2.5 px-4 py-2.5 border-b border-slate-100 dark:border-slate-800">
        <div className={cn('rounded-lg p-1.5 shrink-0', chip)}>{icon}</div>
        <div className="text-xs font-semibold text-slate-600 dark:text-slate-300 truncate">
          {title}
        </div>
      </div>
      <div className="flex flex-1 min-h-[3.25rem] items-end justify-between gap-2 px-4 py-3">
        <div className="min-w-0 text-2xl font-bold font-mono tabular-nums tracking-tight text-slate-900 dark:text-white truncate">
          {value}
        </div>
        {subtitle ? (
          <span className="text-[10px] text-right leading-tight text-slate-400 dark:text-slate-500 shrink-0 max-w-[45%]">
            {subtitle}
          </span>
        ) : (
          delta !== undefined && <Delta value={delta ?? null} positiveIsGood={positiveIsGood} />
        )}
      </div>
    </div>
  );
}

Props

PropTypeDefaultDescription
title*string—
value*string—
icon*React.ReactNode—
tone'indigo' | 'amber' | 'violet' | 'emerald''indigo'
deltanumber | null—
positiveIsGoodbooleantrue
subtitlestring—