Sparkline
A word-sized chart for running text and stat tiles, as line, area or bars, with tooltip and arrow keys.
One of 6 components in KPI, live and map charts
Details
What Sparkline brings along, read from the registry and the source.
- Category
- KPI, live and map charts
- Area
- Elements
- Variants
- 2
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/sparkline.tsx
- Brings along
- utilskitchart
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The SparklineProps interface from components/ui/sparkline.tsx, with the comments from the code. Also takes every prop of ChartA11y.
datarequired(number | Datum)[]Numbers, or rows with valueKey (and xKey for dates in the tooltip)
valueKeyoptionalstringxKeyoptionalstringKey of the date or category in each row, e.g. "day" with "2026-09-14"
variantoptional'line' | 'area' | 'bar'line, area (line with a tint) or bar
formatoptionalIntl.NumberFormatOptionsValue format, e.g. { style: 'currency', currency: 'EUR' }
xFormatoptionalIntl.DateTimeFormatOptionsTooltip format for date values
widthoptionalnumberWidth in px. Without it the sparkline fills its container (not in running text).
heightoptionalnumberHeight in px
toneoptional'accent' | 'muted'accent: the whole line in the accent. muted: grey line, only the last point in the accent.
highlightoptionalnumberBars: index of the bar that keeps the accent, the others step back. Default with tone muted: the last bar.
endDotoptionalbooleanDot on the last value (line, area)
interactiveoptionalbooleanTooltip and arrow keys. Default on.
inlineoptionalbooleanSits in running text: renders inline and aligns with the text.
Accessibility
Read from the source of the component.
- Sparkline handles the keys Space itself.
- Sets aria-describedby, aria-label and aria-live, so screen readers announce purpose and state.
- Uses the role img.
- Announces changes through a live region without moving focus.
- Adds text for screen readers where icons and graphics stand alone.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 171 lines
'use client';
import { useId } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { CHART_TEXTS, ChartRoot, barPath, linePath, numberFormatter, useActive, useEntrance, useIntlLocale, useShown, useWidth, xFormatters, type ChartA11y, type Datum } from '@/components/ui/chart';
export interface SparklineProps extends ChartA11y {
/** Numbers, or rows with valueKey (and xKey for dates in the tooltip) */
data: (number | Datum)[];
valueKey?: string;
/** Key of the date or category in each row, e.g. "day" with "2026-09-14" */
xKey?: string;
/** line, area (line with a tint) or bar */
variant?: 'line' | 'area' | 'bar';
/** Value format, e.g. { style: 'currency', currency: 'EUR' } */
format?: Intl.NumberFormatOptions;
/** Tooltip format for date values */
xFormat?: Intl.DateTimeFormatOptions;
/** Width in px. Without it the sparkline fills its container (not in running text). */
width?: number;
/** Height in px */
height?: number;
/** accent: the whole line in the accent. muted: grey line, only the last point in the accent. */
tone?: 'accent' | 'muted';
/** Bars: index of the bar that keeps the accent, the others step back. Default with tone muted: the last bar. */
highlight?: number;
/** Dot on the last value (line, area) */
endDot?: boolean;
/** Tooltip and arrow keys. Default on. */
interactive?: boolean;
/** Sits in running text: renders inline and aligns with the text. */
inline?: boolean;
className?: string;
}
/**
* A word-sized chart for running text and stat tiles.
* <p>Visits rose <Sparkline inline width={64} data={[312, 340, 298, 402]} label="Visits, last 4 weeks" /> this month.</p>
*/
export function Sparkline({ data, valueKey = 'value', xKey, variant = 'line', format, xFormat, width: fixed, height = 28, tone = 'accent', highlight, endDot = true, interactive = true, inline = false, label, summary, labels, animate = true, className }: SparklineProps) {
const intl = useIntlLocale();
const t = useTexts(CHART_TEXTS, labels);
const fmt = numberFormatter(intl, format);
const uid = useId().replace(/[^a-zA-Z0-9]/g, '');
const [measureRef, measured] = useWidth<HTMLSpanElement>(fixed ?? 120);
const [rootRef, entrance] = useEntrance<HTMLDivElement>(!inline && animate);
const anim = inline ? undefined : entrance;
const values = data.map((d) => (typeof d === 'number' ? d : typeof d[valueKey] === 'number' ? (d[valueKey] as number) : 0));
const n = values.length;
const { active, setActive, onKeyDown, onFocus, onBlur } = useActive(n);
const { shown, open, jump } = useShown(active);
// Crosshair, dot and tooltip glide from value to value, they only appear in place
const glide = jump ? 'transition-opacity duration-200 motion-reduce:transition-none' : 'transition-[transform,opacity] duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none';
const xf = xKey ? xFormatters(intl, (data[0] as Datum)?.[xKey], xFormat, xFormat) : null;
const xOf = (i: number) => (xf && typeof data[i] !== 'number' ? xf.tooltip((data[i] as Datum)[xKey!]) : '');
const w = fixed ?? measured;
const pad = variant === 'bar' ? 0 : 4;
const lo = variant === 'bar' ? Math.min(0, ...values) : Math.min(...values);
const hi = Math.max(...values);
const span = hi - lo || 1;
const y = (v: number) => round(pad + (1 - (v - lo) / span) * (height - pad * 2));
const step = variant === 'bar' ? w / Math.max(1, n) : (w - pad * 2) / Math.max(1, n - 1);
const x = (i: number) => round(variant === 'bar' ? step * (i + 0.5) : pad + step * i);
const pts = values.map((v, i) => [x(i), y(v)] as [number, number]);
const line = linePath(pts, true);
// Plain tokens instead of the chart variables, so an inline sparkline works without the chart styles
const accent = 'var(--primary)';
const soft = 'color-mix(in oklab, var(--primary) 34%, var(--background))';
const stroke = tone === 'muted' ? 'var(--muted-foreground)' : accent;
const first = values[0] ?? 0;
const last = values[n - 1] ?? 0;
const auto = n ? `${fmt(first)} → ${fmt(last)}, min ${fmt(Math.min(...values))}, max ${fmt(hi)}` : '';
const aria = `${label}. ${summary ?? auto}`;
const live = active !== null ? [xOf(active), fmt(values[active])].filter(Boolean).join(', ') : '';
const hintId = `${uid}-hint`;
const pick = (clientX: number, el: Element) => {
const r = el.getBoundingClientRect();
const p = ((clientX - r.left) / (r.width || w)) * w;
const i = variant === 'bar' ? Math.floor(p / step) : Math.round((p - pad) / (step || 1));
setActive(Math.max(0, Math.min(n - 1, i)));
};
const Tag = inline ? 'span' : 'div';
return (
<ChartRoot anim={anim} rootRef={rootRef} inline={inline} className={cn(inline ? 'inline-block align-middle' : 'w-full', className)}>
<Tag className={cn('relative', inline ? 'inline-block align-middle' : 'block w-full')} style={inline || fixed ? { width: w } : undefined}>
<span
ref={measureRef}
role="img"
aria-label={aria}
aria-describedby={interactive ? hintId : undefined}
tabIndex={interactive ? 0 : undefined}
onKeyDown={interactive ? onKeyDown : undefined}
onFocus={interactive ? onFocus : undefined}
onBlur={interactive ? onBlur : undefined}
onPointerMove={interactive ? (e) => pick(e.clientX, e.currentTarget) : undefined}
onPointerDown={interactive ? (e) => pick(e.clientX, e.currentTarget) : undefined}
onPointerLeave={interactive ? (e) => e.pointerType === 'mouse' && setActive(null) : undefined}
className={cn('block touch-pan-y rounded-[3px] outline-offset-2', interactive && 'cursor-crosshair')}
>
<svg width={w} height={height} viewBox={`0 0 ${w} ${height}`} aria-hidden className="block overflow-visible">
{variant === 'bar' ? (
values.map((v, i) => {
const bw = Math.max(1, Math.min(inline ? 5 : 8, step - 2));
const top = y(Math.max(0, v));
const h = Math.abs(y(v) - y(0));
const hi = highlight ?? (tone === 'muted' ? n - 1 : -1);
return (
<path
key={i}
d={barPath(x(i) - bw / 2, v >= 0 ? top : y(0), bw, Math.max(1, h), 1.5, v >= 0 ? 'up' : 'down')}
fill={hi < 0 || i === hi || active === i ? accent : soft}
opacity={active !== null && active !== i ? 0.45 : 1}
className={anim ? 'kitch-grow-y' : undefined}
style={anim ? ({ '--kitch-d': `${round((i / n) * 240, 0)}ms` } as React.CSSProperties) : undefined}
/>
);
})
) : (
<>
{variant === 'area' && (
<>
<defs>
<linearGradient id={`${uid}-a`} x1="0" x2="0" y1="0" y2="1">
<stop offset="0" stopColor={stroke} stopOpacity="0.22" />
<stop offset="1" stopColor={stroke} stopOpacity="0" />
</linearGradient>
</defs>
<path d={`${line} L${x(n - 1)} ${height} L${x(0)} ${height} Z`} fill={`url(#${uid}-a)`} className={anim ? 'kitch-clip' : undefined} />
</>
)}
<path d={line} fill="none" stroke={stroke} strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" pathLength={1} className={anim ? 'kitch-draw' : undefined} />
{endDot && n > 0 && <circle cx={x(n - 1)} cy={y(last)} r="2.75" fill={accent} stroke="var(--card)" strokeWidth="1.5" className={anim ? 'kitch-pop' : undefined} style={anim ? ({ '--kitch-d': '780ms' } as React.CSSProperties) : undefined} />}
{shown !== null && shown < n && (
<>
<line x1={0} x2={0} y1={0} y2={height} stroke="var(--border-strong)" strokeWidth="1" className={glide} style={{ transform: `translateX(${round(x(shown), 0) + 0.5}px)`, opacity: open ? 1 : 0 }} />
<circle cx={0} cy={0} r="3" fill={accent} stroke="var(--card)" strokeWidth="1.5" className={glide} style={{ transform: `translate(${x(shown)}px, ${y(values[shown])}px)`, opacity: open ? 1 : 0 }} />
</>
)}
</>
)}
</svg>
</span>
{interactive && (
<span
aria-hidden
className={cn(
'pointer-events-none absolute bottom-full left-0 z-10 mb-1.5 -translate-x-1/2 whitespace-nowrap rounded-md bg-foreground px-1.5 py-0.5 text-[11px] font-medium leading-5 tabular-nums text-background shadow-[var(--shadow-md)]',
glide,
open ? 'opacity-100' : 'opacity-0',
)}
style={{ transform: `translateX(${shown !== null ? x(shown) : 0}px)` }}
>
{shown !== null && shown < n && (xOf(shown) ? `${xOf(shown)} · ${fmt(values[shown])}` : fmt(values[shown]))}
</span>
)}
{interactive && (
<span id={hintId} className="sr-only">
{t.hint}
</span>
)}
<span aria-live="polite" className="sr-only">
{live}
</span>
</Tag>
</ChartRoot>
);
}Frequent questions
How do I install Sparkline?
With aiw add @ai-website/sparkline. The command puts components/ui/sparkline.tsx into your project, the code is yours from then on. It brings along chart. It needs no further npm packages.
Which variants does Sparkline have?
2 variants, Inline in text and KPI row.
Can an AI assistant build with Sparkline?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Sparkline with aiw_components and install it with aiw_add @ai-website/sparkline. Name the variant in your prompt, for example variant="area", and the assistant writes your content straight into the props.