Sparkline
Preview
Basic
Loading…
Preview
Code
ts
import Sparkline from '@/components/data/Sparkline';src/components/data/Sparkline.tsx
AI prompt
text
Build a sparkline component in React + TypeScript + Tailwind CSS: a word-sized trend for a stat tile or a table cell, where the question is "which way is it going", not "what was it on the 4th". Plain inline SVG — no chart library; at this size a library's axes, margins and resize observers are all cost and no content.
## Look
- Default 96 × 28px SVG, 4px inner padding, no axes, grid or labels.
- Values are scaled min → max into the padded box (a flat series uses a span of 1, so it draws a straight line). Points are evenly spaced left to right and joined with straight segments.
- The history line is the de-emphasis grey — #94a3b8, dark #64748b — 2px with round caps and joins, no fill.
- Only the CURRENT (last) point is in the accent: an 8px circle in #2a78d6 (dark #3987e5) ringed 2px in the card colour (#ffffff / dark #1e293b), so the eye goes to "now".
## Behaviour
- Fewer than two values → render nothing.
- No hover or tooltip; it is a glyph, not a chart.
## API
`values: number[]`, `width = 96`, `height = 28`, `label = 'Trend'` (what is trending, for the accessible name), `className?`.
## Accessibility
`role="img"` with an accessible name that states the numbers the picture cannot: "MRR: from 42 to 64, up 52.4%" — first value, last value, and the change relative to the first (to one decimal, "up" or "down"; 0% when the first value is 0).
## Demo
Three stat tiles in a `sm:grid-cols-3` grid, each a panel with `p-4` and the text and sparkline bottom-aligned at opposite ends: an 11px slate-500 caption over a `text-xl` semibold value, sparkline on the right. MRR "$64K" [42, 44, 43, 47, 49, 48, 52, 55, 54, 58, 61, 64]; Churn "4.6%" [3.1, 3.0, 3.3, 3.2, 3.6, 3.4, 3.8, 3.7, 4.1, 4.0, 4.4, 4.6]; Seats "987" [820, 836, 851, 849, 872, 890, 903, 911, 934, 948, 961, 987].
## 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
'use client';
import { useChartPalette } from './chartTheme';
/**
* A word-sized trend — twelve-odd points and no axes, for a stat tile or a
* table cell, where the question is "which way is it going" and not "what
* was it on the 4th".
*
* The history is the de-emphasis grey and only the CURRENT point is in the
* accent, so the eye goes to now. Plain SVG rather than a chart library: at
* this size a library's axes, margins and resize observers are all cost and
* no content. The accessible name states the first, last and change, since
* the picture alone carries no numbers.
*/
export default function Sparkline({
values,
width = 96,
height = 28,
label = 'Trend',
className,
}: {
values: number[];
width?: number;
height?: number;
/** What is trending — used in the accessible name. */
label?: string;
className?: string;
}) {
const p = useChartPalette();
if (values.length < 2) return null;
const pad = 4;
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min || 1;
const xOf = (i: number) => pad + (i / (values.length - 1)) * (width - pad * 2);
const yOf = (v: number) => pad + (1 - (v - min) / span) * (height - pad * 2);
const d = values.map((v, i) => `${i === 0 ? 'M' : 'L'}${xOf(i).toFixed(1)},${yOf(v).toFixed(1)}`).join(' ');
const first = values[0];
const last = values[values.length - 1];
const change = first ? ((last - first) / Math.abs(first)) * 100 : 0;
return (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
role="img"
aria-label={`${label}: from ${first.toLocaleString()} to ${last.toLocaleString()}, ${change >= 0 ? 'up' : 'down'} ${Math.abs(change).toFixed(1)}%`}
className={className}
>
<path d={d} fill="none" stroke={p.muted} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
<circle cx={xOf(values.length - 1)} cy={yOf(last)} r={4} fill={p.categorical[0]} stroke={p.surface} strokeWidth={2} />
</svg>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
values* | number[] | — | |
width | number | 96 | |
height | number | 28 | |
label | string | 'Trend' | What is trending — used in the accessible name. |
className | string | — |