Skip to content
Dethink Components

ComponentsStat Tile

Stat Tile

The number first, then what changed, then how it got here. Group tiles into a KPI row that wraps cleanly from phone to wide dashboard.

On this page

Install component source from the public registry, or use the local workspace package. Follow the setup guide first. The npm package is not published yet.

View registry files

Implemented with React and browser APIs, without an additional behavior or rendering library.

Shared foundation: React, clsx for conditional classes, tailwind-merge for resolving utility-class conflicts. Styles use Tailwind CSS and Dethink CSS variables.

Based on this component’s source and registry composition. Libraries used only in showcase examples are excluded.

Import the component into your page or component file.

Usage
import { KpiGroup, StatTile } from "@dethink/components";

A joined KPI panel, linked cards with compact trends, and loading, missing and large-value states.

KPI row

A joined panel of four metrics. Churn and latency set positiveDirection to down, so an increase in latency reads as a regression.

Monthly recurring revenue
$128.4K
Up 8.2% vs last month
Active workspaces
12.4K
Up 3.1% vs last month
Logo churn
1.8%
Down 0.4% vs last month
API p95 latency
182 ms
Up 6.5% vs last week
Show sourceexamples/stat-tile/dashboard-row.tsx
examples/stat-tile/dashboard-row.tsx
"use client";

import { KpiGroup, StatTile } from "@dethink/components";

export function StatTileDashboardRow() {
  return (
    <KpiGroup
      aria-label="Workspace health, last 30 days"
      variant="joined"
      className="w-full"
    >
      <StatTile
        label="Monthly recurring revenue"
        value={128_430}
        formatOptions={{ style: "currency", currency: "USD" }}
        delta={8.2}
        comparison="vs last month"
        trend={[92, 96, 95, 101, 104, 103, 109, 113, 112, 118, 124, 128]}
        trendColor="chart-1"
      />
      <StatTile
        label="Active workspaces"
        value={12_408}
        delta={3.1}
        comparison="vs last month"
        trend={[
          10.9, 11, 11.2, 11.1, 11.4, 11.6, 11.7, 11.9, 12, 12.1, 12.3, 12.4,
        ]}
        trendColor="chart-3"
      />
      <StatTile
        label="Logo churn"
        value="1.8%"
        delta={{ value: -0.4, positiveDirection: "down" }}
        comparison="vs last month"
        trend={[2.6, 2.5, 2.7, 2.4, 2.3, 2.2, 2.4, 2.1, 2, 2.2, 1.9, 1.8]}
        trendColor="chart-2"
      />
      <StatTile
        label="API p95 latency"
        value={182}
        formatValue={(value) => `${value} ms`}
        delta={{
          value: 6.5,
          positiveDirection: "down",
        }}
        comparison="vs last week"
        trend={[168, 171, 166, 170, 174, 169, 172, 176, 175, 179, 177, 182]}
        trendColor="chart-7"
      />
    </KpiGroup>
  );
}

Linked tiles

Separate cards that navigate to a detail view, with the trend beside the value. A tiny change below the threshold reads as flat.

Show sourceexamples/stat-tile/separate-tiles.tsx
examples/stat-tile/separate-tiles.tsx
"use client";

import { KpiGroup, StatTile } from "@dethink/components";
import { CreditCard, ShoppingBag, Users } from "lucide-react";

export function StatTileSeparate() {
  return (
    <KpiGroup
      aria-label="Store performance today"
      minTileWidth="15rem"
      className="w-full"
    >
      <StatTile
        href="#orders"
        icon={<ShoppingBag />}
        label="Orders"
        value={1_842}
        delta={12.4}
        comparison="vs yesterday"
        trend={[40, 52, 48, 61, 58, 72, 80, 76, 91, 104]}
        trendVariant="bar"
        trendColor="chart-1"
        trendPlacement="end"
      />
      <StatTile
        href="#customers"
        icon={<Users />}
        label="New customers"
        value={318}
        delta={-2.3}
        comparison="vs yesterday"
        trend={[36, 35, 35.5, 34, 33.5, 33, 32, 32.5, 31.5, 31]}
        trendVariant="line"
        trendColor="chart-5"
        trendPlacement="end"
      />
      <StatTile
        href="#payments"
        icon={<CreditCard />}
        label="Payment success"
        value="99.2%"
        delta={{ value: 0.05, neutralThreshold: 0.1 }}
        comparison="vs yesterday"
        caption="Within the 99% target"
        trend={[98.6, 98.8, 98.7, 98.9, 99, 99.1, 99, 99.2]}
        trendVariant="line"
        trendColor="chart-6"
        trendPlacement="end"
      />
    </KpiGroup>
  );
}

Loading, missing and large values

Skeletons keep the layout while loading. Missing data shows a dash with an explanation instead of a zero.

Net revenue
Refund rate
—
No change data vs last week

Data resumes after tonight's sync

Annual contract value
$2.4M
Up 18% year over year
Show sourceexamples/stat-tile/states.tsx
examples/stat-tile/states.tsx
"use client";

import { useState } from "react";
import { Button, KpiGroup, StatTile } from "@dethink/components";

export function StatTileStates() {
  const [loading, setLoading] = useState(true);

  return (
    <div className="grid w-full gap-4">
      <KpiGroup aria-label="Tile states" className="w-full">
        <StatTile
          label="Net revenue"
          value={48_210}
          formatOptions={{ style: "currency", currency: "USD" }}
          delta={5.6}
          comparison="vs last week"
          trend={[31, 35, 34, 39, 41, 44, 48]}
          loading={loading}
          size="sm"
        />
        <StatTile
          label="Refund rate"
          value={null}
          delta={null}
          comparison="vs last week"
          caption="Data resumes after tonight's sync"
          size="sm"
        />
        <StatTile
          label="Annual contract value"
          value={2_400_000}
          formatOptions={{ style: "currency", currency: "USD" }}
          delta={18}
          comparison="year over year"
          size="lg"
        />
      </KpiGroup>
      <div>
        <Button
          size="sm"
          variant="outline"
          aria-pressed={loading}
          onClick={() => setLoading((v) => !v)}
        >
          {loading ? "Finish loading" : "Show loading"}
        </Button>
      </div>
    </div>
  );
}
StatTile props
PropWhat it doesDefault
labelReactNodeSentence-case metric name without a trailing colon. Names the tile's group.Not set
valuenumber | string | nullNumbers format compactly (1,284 · 12.9K · $4.2M); strings render as given; null shows a dash.Not set
formatValue / formatOptions(value) => string / Intl.NumberFormatOptions & { locale }Replace the formatter, or tune it with currency, percent and locale options.compact
deltanumber | DeltaBadgePropsA signed change, or full DeltaBadge props such as positiveDirection.Not set
comparisonstringShown beside the delta and included in its announcement, e.g. "vs last month".Not set
trend / trendColor / trendVariantChartValue[] / ChartColor / "line" | "area" | "bar"Recent values drawn as a Sparkline.— / "chart-1" / "area"
trendPlacement"bottom" | "end"Full-width under the value, or compact beside it."bottom"
trendLabelstringAccessible name for the trend when the label is not text."{label} trend"
icon / captionReactNodeDecorative icon beside the label; supporting text under the value.Not set
loadingbooleanKeeps the layout and swaps content for skeletons; sets aria-busy.false
href / target / relstringRender the tile as a link. New-tab links get noopener noreferrer.Not set
interactivebooleanHover and focus styling when you wrap the tile in your own router link.false
size"sm" | "md" | "lg"Scales the value and trend height."md"
KpiGroup props
PropWhat it doesDefault
variant"separate" | "joined"Separate cards with a gap, or one framed panel with hairline dividers."separate"
minTileWidthstringNarrowest tile width before the row wraps. Tiles stretch to fill each row."13rem"
aria-label / aria-labelledbystringWhen present, the row becomes a named group.Not set

Each tile is a group named by its label. The delta is announced as a sentence such as “Up 8.2% vs last month”, so the visible comparison is not read twice. The trend is an image with a summary of its range. Linked tiles are a single link whose name includes the label and value. Wrap a tile in your router’s link and set interactive to get the same hover and focus treatment. Loading tiles set aria-busy and keep their size, so nothing shifts when data arrives.