Stat tile
A title with an info dot, a flowing number, a delta chip and a sparkline that draws itself in. By default it is a live revenue tile; pass `live={false}` with your own `series` for a static one.
Code
1. Install
npm i framer-motion lucide-react
2. Files
"use client";
// StatTile: a title with an info dot, a flowing number, a delta chip and a sparkline that draws itself in.
// By default it is a live revenue tile; pass `live={false}` with your own `series` for a static one.
import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
import { useReducedMotion } from "framer-motion";
import { ArrowDownRight, ArrowUpRight, Info } from "lucide-react";
export type StatTileProps = {
/** Small heading. */
title?: string;
/** Tooltip text behind the info dot. Leave empty for no dot. */
info?: string;
/** Headline number. Defaults to `toValue` of the latest series point. */
value?: number;
/** Maps the latest series point to the headline number when `value` is not set. */
toValue?: (latest: number) => number;
/** Text before the headline number. */
prefix?: string;
/** Percent change vs. the previous period. Defaults to the change between the last two series points. */
delta?: number;
/** What the delta is measured against, e.g. "vs. September". */
against?: string;
/** Recent values for the sparkline, oldest first. */
series?: number[];
/** Keep appending a new random point to the series every `liveEvery` ms (demo behaviour). */
live?: boolean;
/** Tile width in px. */
width?: number;
className?: string;
style?: CSSProperties;
};
export const DEFAULT_STAT_SERIES = [42, 48, 45, 52, 58, 55, 61, 66, 63, 70, 74, 71];
const SPARK_WIDTH = 96;
const SPARK_HEIGHT = 34;
const LIVE_EVERY_MS = 2600;
export function StatTile({
title = "Monthly revenue",
info = "Net of refunds, settled in USD.",
value,
toValue = (n) => n * 1000 + 340,
prefix = "$",
delta,
against = "vs. last month",
series = DEFAULT_STAT_SERIES,
live = true,
width = 280,
className,
style,
}: StatTileProps) {
const id = useId();
const [tip, setTip] = useState(false);
const [own, setOwn] = useState(series);
useEffect(() => setOwn(series), [series]);
useEffect(() => {
if (!live) return;
const t = setInterval(() => setOwn((v) => [...v.slice(1), Math.max(10, v[v.length - 1] + Math.round((Math.random() - 0.42) * 14))]), LIVE_EVERY_MS);
return () => clearInterval(t);
}, [live]);
const s = live ? own : series;
const last = s[s.length - 1] ?? 0;
const before = s[s.length - 2];
const shown = value ?? toValue(last);
const change = delta ?? (before ? ((last - before) / before) * 100 : undefined);
const up = (change ?? 0) >= 0;
const spark = s.length > 1 ? sparkPath(s, SPARK_WIDTH, SPARK_HEIGHT) : null;
return (
<div className={className ? `stl-root ${className}` : "stl-root"} style={{ width, ...style }}>
<div className="stl-head">
<span className="stl-title">{title}</span>
{info && (
<span className="stl-info" onPointerEnter={() => setTip(true)} onPointerLeave={() => setTip(false)} onFocus={() => setTip(true)} onBlur={() => setTip(false)} onKeyDown={(e) => e.key === "Escape" && setTip(false)} tabIndex={0} role="img" aria-label={`About ${title}`} aria-describedby={`${id}-tip`}>
<Info size={14} strokeWidth={2} aria-hidden />
<span id={`${id}-tip`} role="tooltip" className="stl-tip" data-on={tip || undefined}>
{info}
</span>
</span>
)}
</div>
<div className="stl-mid">
<Flow value={shown} prefix={prefix} size={30} />
{spark && (
<svg className="stl-spark" width={SPARK_WIDTH} height={SPARK_HEIGHT} viewBox={`0 0 ${SPARK_WIDTH} ${SPARK_HEIGHT}`} aria-hidden>
<path className="stl-line" d={spark.line} pathLength={1} />
<circle className="stl-dot" cx={spark.last.x} cy={spark.last.y} r={2.5} />
</svg>
)}
</div>
{change !== undefined && (
<div className="stl-row">
<span className="stl-delta" data-up={up || undefined}>
{up ? <ArrowUpRight size={13} strokeWidth={2.4} aria-hidden /> : <ArrowDownRight size={13} strokeWidth={2.4} aria-hidden />}
<Flow value={Math.abs(change)} format={{ maximumFractionDigits: 1 }} suffix="%" trend={false} size={12} />
</span>
{against && <span className="stl-against">{against}</span>}
</div>
)}
</div>
);
}
const SPARK_PAD_X = { left: 1, right: 4 };
const SPARK_PAD_Y = 4;
const fixed = (n: number) => n.toFixed(1);
/** Smooth Catmull-Rom → bezier through the series, laid edge to edge with headroom top and bottom and room for the pip on the right. */
function sparkPath(series: number[], width: number, height: number) {
const low = Math.min(...series);
const range = Math.max(...series) - low || 1;
const left = SPARK_PAD_X.left;
const usableWidth = width - SPARK_PAD_X.right - left;
const usableHeight = height - 2 * SPARK_PAD_Y;
const points = series.map((value, i) => ({
x: left + (i / (series.length - 1)) * usableWidth,
y: SPARK_PAD_Y + (1 - (value - low) / range) * usableHeight,
}));
const segments = [`M${fixed(points[0].x)},${fixed(points[0].y)}`];
for (let i = 0; i < points.length - 1; i++) {
const before = points[Math.max(0, i - 1)];
const from = points[i];
const to = points[i + 1];
const after = points[Math.min(points.length - 1, i + 2)];
const out = `${fixed(from.x + (to.x - before.x) / 6)},${fixed(from.y + (to.y - before.y) / 6)}`;
const into = `${fixed(to.x - (after.x - from.x) / 6)},${fixed(to.y - (after.y - from.y) / 6)}`;
segments.push(`C${out} ${into} ${fixed(to.x)},${fixed(to.y)}`);
}
return { line: segments.join(" "), last: points[points.length - 1] };
}
type Cell = { key: string; ch: string; digit: boolean; fresh: boolean; i: number };
type FlowProps = { value: number; format?: Intl.NumberFormatOptions; prefix?: string; suffix?: string; trend?: boolean; size: number };
/** A number whose changed digits pop in one after another out of a slight blur, from the right. */
function Flow({ value, format, prefix = "", suffix = "", trend = true, size }: FlowProps) {
const still = useReducedMotion();
const text = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2, ...format }).format(value);
const prev = useRef<{ text: string; value: number }>({ text, value });
const keys = useRef<number[]>([]); // per right-index: gen in which that cell last changed
const gen = useRef(0);
const [cells, setCells] = useState<Cell[]>(() => splitCells(text, "", 0, keys.current));
const [dir, setDir] = useState<"up" | "down" | null>(null);
const tmr = useRef(0);
useEffect(() => {
const p = prev.current;
if (p.text === text) return;
const d = value > p.value ? "up" : value < p.value ? "down" : null;
prev.current = { text, value };
setCells(splitCells(text, p.text, ++gen.current, keys.current));
if (trend && d) {
setDir(d);
clearTimeout(tmr.current);
tmr.current = window.setTimeout(() => setDir(null), 900);
}
}, [text, value, trend]);
useEffect(() => () => clearTimeout(tmr.current), []);
// When the digit count changes, glide the row's width instead of letting the number re-centre with a cut.
const row = useRef<HTMLSpanElement>(null);
const w = useRef(0);
useLayoutEffect(() => {
const el = row.current;
if (!el) return;
const from = el.style.width ? el.offsetWidth : w.current;
el.style.width = "";
const to = (w.current = el.offsetWidth);
if (!from || still || Math.abs(from - to) < 1) return;
el.style.width = `${from}px`;
void el.offsetWidth; // commit the start width before transitioning
el.style.width = `${to}px`;
const done = (e: TransitionEvent) => e.propertyName === "width" && ((el.style.width = ""), el.removeEventListener("transitionend", done));
el.addEventListener("transitionend", done);
}, [cells, still]);
return (
<span className="stl-num" data-dir={dir ?? undefined} data-still={still || undefined} style={{ fontSize: size, fontWeight: 500, "--stl-stagger": "36ms" } as CSSProperties}>
<span className="stl-sr">
{prefix}
{text}
{suffix}
</span>
<span ref={row} className="stl-line-n" aria-hidden>
{prefix && <span className="stl-aff">{prefix}</span>}
{cells.map((c) => (
<span key={c.key} className={c.digit ? "stl-cell" : "stl-cell stl-sep"} data-fresh={c.fresh || undefined} style={{ "--stl-i": c.i } as CSSProperties}>
{c.ch}
</span>
))}
{suffix && <span className="stl-aff">{suffix}</span>}
</span>
</span>
);
}
/** Compare from the right: a cell is "fresh" when the character at that position (from the end) changed. */
function splitCells(next: string, previous: string, generation: number, generations: number[]): Cell[] {
const cells: Cell[] = [];
const lengthShift = next.length - previous.length;
let freshCount = 0;
for (let index = next.length - 1; index >= 0; index--) {
const char = next[index];
const fromEnd = next.length - 1 - index;
const fresh = previous !== "" && char !== previous[index - lengthShift];
if (fresh || generations[fromEnd] === undefined) generations[fromEnd] = generation;
cells.unshift({ ch: char, digit: /\d/.test(char), fresh, i: fresh ? freshCount : 0, key: `${fromEnd}-${generations[fromEnd]}` });
if (fresh) freshCount++;
}
generations.length = next.length;
return cells;
}
/* StatTile. Prefix stl-. */
.stl-root,
.stl-root * {
box-sizing: border-box;
}
.stl-root {
--stl-ease: cubic-bezier(.22,1,.36,1);
--stl-pop: cubic-bezier(.24,1.34,.38,1);
position: relative;
padding: 18px 20px 16px;
border-radius: var(--ts-r-panel);
background: var(--ts-raised);
color: var(--ts-fg);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
box-shadow: var(--ts-edge);
font-family: var(--ts-font, system-ui, sans-serif);
}
.stl-head {
display: flex;
align-items: center;
gap: 6px;
}
.stl-title {
font-size: 13px;
font-weight: 500;
color: color-mix(in srgb, var(--ts-raised), var(--ts-fg) 62%);
}
.stl-info {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
height: 20px;
aspect-ratio: 1;
border-radius: 50%;
cursor: help;
color: color-mix(in srgb, var(--ts-raised), var(--ts-fg) 40%);
transition: color 0.16s var(--stl-ease);
}
.stl-info:hover,
.stl-info:focus-visible {
color: var(--ts-fg);
}
.stl-info:focus-visible {
outline: 2px solid var(--ts-accent);
outline-offset: 2px;
}
.stl-tip {
position: absolute;
left: 50%;
bottom: calc(100% + 6px);
translate: -50% 0;
width: max-content;
max-width: 200px;
padding: 7px 10px;
border-radius: var(--ts-r-pop);
background: var(--ts-fg);
color: var(--ts-raised);
font-size: 12px;
font-weight: 400;
line-height: 1.35;
text-align: center;
pointer-events: none;
opacity: 0;
scale: 0.92;
filter: blur(4px);
transform-origin: 50% 100%;
transition: opacity 0.16s var(--stl-ease), scale 0.42s var(--stl-pop), filter 0.16s var(--stl-ease);
}
.stl-tip[data-on] {
opacity: 1;
scale: 1;
filter: none;
}
.stl-mid {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
margin-top: 2px;
}
.stl-row {
display: inline-flex;
gap: 8px;
align-items: center;
}
.stl-delta {
display: inline-flex;
align-items: center;
gap: 2px;
height: 22px;
padding: 0 8px 0 5px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
color: var(--ts-danger);
background: color-mix(in srgb, var(--ts-danger) 12%, transparent);
transition: background 0.22s var(--stl-ease), color 0.22s var(--stl-ease);
}
.stl-delta[data-up] {
color: var(--ts-success);
background: color-mix(in srgb, var(--ts-success) 12%, transparent);
}
.stl-delta .stl-num {
--stl-stagger: 36ms;
color: inherit;
}
.stl-against {
font-size: 12px;
color: color-mix(in srgb, var(--ts-raised), var(--ts-fg) 48%);
}
.stl-spark {
flex: none;
overflow: visible;
color: var(--ts-fg);
}
.stl-line {
fill: none;
stroke: currentColor;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0.45;
stroke-dasharray: 1;
stroke-dashoffset: 0;
animation: stl-draw 0.9s var(--stl-ease) both;
transition: d 0.6s var(--stl-ease);
}
@keyframes stl-draw {
0% {
stroke-dashoffset: 1;
}
}
.stl-dot {
fill: currentColor;
transform-box: fill-box;
transform-origin: center;
animation: stl-pip 0.42s var(--stl-pop) 0.8s both;
transition: cx 0.6s var(--stl-ease), cy 0.6s var(--stl-ease);
}
@keyframes stl-pip {
0% {
scale: 0;
opacity: 0;
}
}
/* the flowing number */
.stl-num {
--stl-stagger: 36ms;
position: relative;
display: inline-block;
color: var(--ts-fg);
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
line-height: 1;
transition: color 0.22s var(--stl-ease);
}
.stl-num[data-dir="up"] {
color: var(--ts-success);
}
.stl-num[data-dir="down"] {
color: var(--ts-danger);
}
.stl-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.stl-line-n {
display: inline-flex;
align-items: baseline;
justify-content: flex-end; /* while the width glides, a new leading digit overflows to the left */
white-space: pre;
transition: width 0.42s var(--stl-ease);
}
.stl-cell {
--stl-i: 0;
display: inline-block;
}
.stl-sep {
opacity: 0.55;
}
.stl-aff {
opacity: 0.55;
font-size: 0.7em;
margin: 0 0.08em;
}
.stl-cell[data-fresh] {
animation: stl-in 0.42s var(--stl-pop) backwards;
animation-delay: calc(var(--stl-i) * var(--stl-stagger, 36ms));
}
@keyframes stl-in {
0% {
opacity: 0;
filter: blur(5px);
scale: 0.6;
translate: 0 0.18em;
}
to {
opacity: 1;
filter: none;
scale: 1;
translate: 0 0;
}
}
.stl-num[data-still] .stl-cell[data-fresh] {
animation: none;
}
@media (prefers-reduced-motion: reduce) {
.stl-cell[data-fresh],
.stl-line,
.stl-dot {
animation: none;
}
}
/*
* Teseria tokens — the only colours, fonts and corner radii a block reads.
*
* Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
* the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
* any ancestor, or the OS setting unless [data-theme="light"] is set.
*
* Surfaces page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
* Text fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
* Lines border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
* dark; composes with other shadows)
* Signals accent · focus · success · warning · danger
* Corners controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
* (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
*/
:root {
--ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ts-r-panel: 24px;
--ts-r-pop: 18px;
--ts-r-inner: 12px;
--ts-page: #ffffff;
--ts-raised: #ffffff;
--ts-sunken: #f3f2f0;
--ts-tint-1: #f5f4f2;
--ts-tint-2: #ecebe8;
--ts-tint-3: #e3e1dd;
--ts-fg: #161514;
--ts-fg-rgb: 22, 21, 20;
--ts-fg-secondary: #3a3836;
--ts-fg-tertiary: #585552;
--ts-fg-muted: #6d6a66;
--ts-fg-faint: #89857f;
--ts-fg-inverse: #ffffff;
--ts-border: rgba(22, 21, 20, 0.1);
--ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);
--ts-accent: #3446eb;
--ts-focus: #3446eb;
--ts-success: #13804a;
--ts-warning: #a9650b;
--ts-danger: #df474e;
}
:root[data-theme="dark"] {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
}
3. Usage
import { StatTile } from "./StatTile";
import "./StatTile.css";
export default function Demo() {
return <StatTile />;
}