Skip to content
UI Kit
Accent color

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

2 variants

Variants

Inline in text

Sparklines sit in the line of text and align with it, every one with its own accessible name.

KPI row

Four stat tiles, each with a sparkline that fills the tile. Area, line, bars and a quiet grey line.

Details

What Sparkline brings along, read from the registry and the source.

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

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)

  • valueKeyoptional
    string
  • xKeyoptional
    string

    Key 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

  • formatoptional
    Intl.NumberFormatOptions

    Value format, e.g. { style: 'currency', currency: 'EUR' }

  • xFormatoptional
    Intl.DateTimeFormatOptions

    Tooltip format for date values

  • widthoptional
    number

    Width in px. Without it the sparkline fills its container (not in running text).

  • heightoptional
    number

    Height in px

  • toneoptional
    'accent' | 'muted'

    accent: the whole line in the accent. muted: grey line, only the last point in the accent.

  • highlightoptional
    number

    Bars: index of the bar that keeps the accent, the others step back. Default with tone muted: the last bar.

  • endDotoptional
    boolean

    Dot on the last value (line, area)

  • interactiveoptional
    boolean

    Tooltip and arrow keys. Default on.

  • inlineoptional
    boolean

    Sits 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
components/ui/sparkline.tsx
'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.

Often searched

Jump to

Copied to the clipboard