v1.0

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

PropTypeDefaultDescription
values*number[]—
widthnumber96
heightnumber28
labelstring'Trend'What is trending — used in the accessible name.
classNamestring—