Teseria
All elements

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

Terminal
npm i framer-motion lucide-react

2. Files

StatTile.tsx213 lines
"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.css223 lines
/* 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;
  }
}
tokens.cssshared · add once
/*
 * 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

Demo.tsx
import { StatTile } from "./StatTile";
import "./StatTile.css";

export default function Demo() {
  return <StatTile />;
}