Live chart
Values that keep coming in, as a sliding line, growing bars, an event track with a feed, live tiles or a latency band with a limit. It holds still while you read and has a pause button.
One of 6 components in KPI, live and map charts
Variants
Details
What Live chart brings along, read from the registry and the source.
- Category
- KPI, live and map charts
- Area
- Elements
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/live-chart.tsx
- Brings along
- utilskiticonchartchart-figure
- npm packages
- None
- Languages
- English and German, 12 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The LiveChartProps interface from components/ui/live-chart.tsx, with the comments from the code. Also takes every prop of Omit<ChartA11y, 'label'>, ChartFigureProps.
labeloptionalstringAccessible name of the chart. Default: the title.
variantoptionalLiveVariantlinebarseventsmultilatency
seriesoptionalLiveSeries[]line, bars: one series. multi: several. latency: two (typical and slow).
eventsoptionalLiveEvent[]events: the events so far
eventNamesoptionalstring[]events: names the simulation draws new events from, "#" becomes a running number
limitoptionalnumberlatency: the limit line
limitLabeloptionalstringwindowoptionalnumberPoints on screen. Default 40.
intervaloptionalnumberMilliseconds between ticks. Default 1000.
simulateoptionalbooleanInvent new values inside min and max (demos, previews)
formatoptionalIntl.NumberFormatOptions
Built-in texts
Live chart writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 12 texts
| Key | English | German |
|---|---|---|
| pause | English Pause updates | German Aktualisierung anhalten |
| play | English Resume updates | German Aktualisierung fortsetzen |
| live | English Live | German Live |
| paused | English Paused | German Angehalten |
| held | English Held while you read | German Angehalten, solange du liest |
| ago | English {n} s ago | German vor {n} s |
| now | English now | German jetzt |
| latest | English Latest | German Zuletzt |
| time | English Time | German Zeit |
| value | English Value | German Wert |
| event | English Event | German Ereignis |
| perSec | English per second | German pro Sekunde |
Accessibility
Read from the source of the component.
- Live chart handles the keys Escape, Enter, Arrow left, Arrow right and Space itself.
- Sets aria-label, aria-live, aria-pressed and aria-relevant, so screen readers announce purpose and state.
- Announces changes through a live region without moving focus.
- Adds text for screen readers where icons and graphics stand alone.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 450 lines
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { ChartRoot, ChartSurface, ChartTable, Swatch, barPath, linePath, niceTicks, numberFormatter, textWidth, tickFormatter, useEntrance, useIntlLocale, useWidth, type ChartA11y } from '@/components/ui/chart';
import { ChartFigure, HALO, PlotAxes, linear, pairColor, type ChartFigureProps } from '@/components/ui/chart-figure';
/*
* Values that keep coming in.
* line one measure as a line that slides left, the newest point pulses
* bars one bar per second, new bars grow in on the right
* events single events (orders, sign ups) as dots on a running time
* track, the newest in a short feed below
* multi several measures as small live tiles with their current value
* latency a typical and a slow value (p50 and p95) as a band, a limit line
*
* Feed it either way: pass new `data` from your source (socket, polling)
* and the chart slides, or set `simulate` for a demo that invents values
* inside a band. The chart stops while the pointer or the keyboard is on it,
* and a button pauses it for good (moving content needs a pause, WCAG
* 2.2.2). With reduced motion the values still update, nothing slides.
*/
export type LiveVariant = 'line' | 'bars' | 'events' | 'multi' | 'latency';
export interface LiveSeries {
label: string;
/** Oldest first, one value per tick */
values: number[];
/** Range of the simulation and of the scale */
min?: number;
max?: number;
format?: Intl.NumberFormatOptions;
/** Text after the value, e.g. " %" or " ms" */
suffix?: string;
}
export interface LiveEvent {
label: string;
value: number;
/** Seconds ago */
ago: number;
}
export interface LiveChartProps extends Omit<ChartA11y, 'label'>, ChartFigureProps {
/** Accessible name of the chart. Default: the title. */
label?: string;
variant?: LiveVariant;
/** line, bars: one series. multi: several. latency: two (typical and slow). */
series?: LiveSeries[];
/** events: the events so far */
events?: LiveEvent[];
/** events: names the simulation draws new events from, "#" becomes a running number */
eventNames?: string[];
/** latency: the limit line */
limit?: number;
limitLabel?: string;
/** Points on screen. Default 40. */
window?: number;
/** Milliseconds between ticks. Default 1000. */
interval?: number;
/** Invent new values inside min and max (demos, previews) */
simulate?: boolean;
format?: Intl.NumberFormatOptions;
className?: string;
}
const TEXTS = {
en: { pause: 'Pause updates', play: 'Resume updates', live: 'Live', paused: 'Paused', held: 'Held while you read', ago: '{n} s ago', now: 'now', latest: 'Latest', time: 'Time', value: 'Value', event: 'Event', perSec: 'per second' },
de: { pause: 'Aktualisierung anhalten', play: 'Aktualisierung fortsetzen', live: 'Live', paused: 'Angehalten', held: 'Angehalten, solange du liest', ago: 'vor {n} s', now: 'jetzt', latest: 'Zuletzt', time: 'Zeit', value: 'Wert', event: 'Ereignis', perSec: 'pro Sekunde' },
};
const CSS = '@keyframes kitlive-shift{from{transform:translateX(var(--kitlive-step))}}@keyframes kitlive-ping{0%{transform:scale(1);opacity:.55}100%{transform:scale(3);opacity:0}}@keyframes kitlive-in{from{opacity:0;transform:translateY(-6px)}}.kitlive-shift{animation:kitlive-shift var(--kitlive-t) linear both}.kitlive-ping{transform-box:fill-box;transform-origin:center;animation:kitlive-ping 1.6s cubic-bezier(.2,.7,.3,1) infinite}.kitlive-in{animation:kitlive-in .45s var(--ease-kit) both}@media (prefers-reduced-motion:reduce){.kitlive-shift,.kitlive-ping,.kitlive-in{animation:none!important}}';
/** The running buffers: follows new props, or invents values with simulate */
function useStream(series: LiveSeries[], window: number, interval: number, simulate: boolean, hold: boolean) {
const [bufs, setBufs] = useState(() => series.map((s) => s.values.slice(-window)));
const [tick, setTick] = useState(0);
const holdRef = useRef(hold);
holdRef.current = hold;
// New data from outside: take it and count a tick so the line slides
const sig = series.map((s) => s.values.length + ':' + s.values[s.values.length - 1]).join('|');
const first = useRef(true);
useEffect(() => {
if (first.current) {
first.current = false;
return;
}
if (simulate) return;
setBufs(series.map((s) => s.values.slice(-window)));
setTick((t) => t + 1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sig]);
useEffect(() => {
if (!simulate) return;
const id = setInterval(() => {
if (holdRef.current) return;
setBufs((prev) =>
prev.map((b, i) => {
const s = series[i];
const lo = s.min ?? Math.min(...s.values);
const hi = s.max ?? Math.max(...s.values);
const last = b[b.length - 1] ?? (lo + hi) / 2;
const span = hi - lo || 1;
// A walk that leans back to the middle, so it never sticks at an edge
const pull = ((lo + hi) / 2 - last) * 0.12;
const next = Math.max(lo, Math.min(hi, last + pull + (Math.random() - 0.5) * span * 0.22));
return [...b.slice(-(window - 1)), round(next, 2)];
}),
);
setTick((t) => t + 1);
}, interval);
return () => clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [simulate, interval, window]);
return { bufs, tick };
}
/**
* Values that keep coming in: a sliding line, growing bars, an event track, live tiles or a latency band.
* <LiveChart variant="line" series={[{ label: 'Visitors now', values: last40, min: 0, max: 120 }]} simulate />
*/
export function LiveChart(props: LiveChartProps) {
const { variant = 'line', series = [], events = [], eventNames = [], limit, limitLabel, window: win = 40, interval = 1000, simulate = false, format, label, summary, labels, animate = true, title, value, delta, meta, source, framed, className } = props;
const t = useTexts(TEXTS, undefined);
const intl = useIntlLocale();
const [wrapRef, width] = useWidth<HTMLDivElement>(720);
const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
const [paused, setPaused] = useState(false);
const [hover, setHover] = useState<number | null>(null);
const [reduced, setReduced] = useState(false);
useEffect(() => setReduced(prefersReducedMotion()), []);
const hold = paused || hover !== null || anim === 'wait';
const S = variant === 'events' ? [{ label: 'events', values: [0] }] : series;
const { bufs, tick } = useStream(S, win, interval, simulate && variant !== 'events', hold);
const clipId = useId().replace(/:/g, '');
const name = label ?? title ?? series[0]?.label ?? '';
const phone = width < 520;
const fmtOf = (s?: LiveSeries) => {
const f = numberFormatter(intl, s?.format ?? format);
return (n: number) => f(n) + (s?.suffix ?? '');
};
const step = (w: number) => w / Math.max(1, win - 1);
const shiftStyle = (px: number) => (!reduced && tick > 0 && !hold ? ({ '--kitlive-step': `${round(px)}px`, '--kitlive-t': `${interval}ms` } as React.CSSProperties) : undefined);
// Events: their own simulation
const [evts, setEvts] = useState<LiveEvent[]>(events);
const [clock, setClock] = useState(0);
const counter = useRef(1040);
useEffect(() => {
if (variant !== 'events') return;
const id = setInterval(() => {
if (paused || hover !== null) return;
setClock((c) => c + 1);
setEvts((prev) => {
const aged = prev.map((e) => ({ ...e, ago: e.ago + interval / 1000 })).filter((e) => e.ago <= 60);
if (simulate && Math.random() < 0.45 && eventNames.length) {
counter.current += 1;
const nm = eventNames[Math.floor(Math.random() * eventNames.length)].replace('#', `#${counter.current}`);
const base = prev.length ? prev.reduce((a, e) => a + e.value, 0) / prev.length : 40;
aged.push({ label: nm, value: round(Math.max(5, base * (0.4 + Math.random() * 1.3)), 2), ago: 0 });
}
return aged;
});
}, interval);
return () => clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [variant, simulate, interval, paused, hover]);
const status = (
<button
type="button"
onClick={() => setPaused((p) => !p)}
aria-pressed={paused}
aria-label={paused ? t.play : t.pause}
className="relative inline-flex h-9 items-center gap-2 rounded-md border border-border px-2.5 text-[12px] font-medium text-foreground transition-colors hover:bg-accent after:absolute after:-inset-y-1 after:inset-x-0 after:content-['']"
>
<span aria-hidden className="relative flex size-2">
{!paused && hover === null && <span className="absolute inset-0 animate-ping rounded-full bg-success opacity-60 motion-reduce:animate-none" />}
<span className={cn('relative size-2 rounded-full', paused ? 'bg-muted-foreground' : 'bg-success')} />
</span>
{paused ? t.paused : t.live}
<Icon name={paused ? 'play' : 'pause'} size={14} className="text-muted-foreground" />
</button>
);
const agoText = (k: number, len: number) => {
const s = Math.round(((len - 1 - k) * interval) / 1000);
return s <= 0 ? t.now : t.ago.replace('{n}', String(s));
};
/* ---------------- multi: live tiles ---------------- */
if (variant === 'multi') {
const cols = width >= 720 ? Math.min(3, series.length) : width >= 440 ? 2 : 1;
return (
<ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} actions={status} className={className}>
<ChartRoot anim={anim} rootRef={rootRef}>
<style>{CSS}</style>
<div ref={wrapRef}>
<ul className="grid gap-px overflow-hidden rounded-lg border border-border bg-border" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }} onPointerEnter={(e) => e.pointerType === 'mouse' && setHover(0)} onPointerLeave={() => setHover(null)}>
{series.map((s, i) => {
const b = bufs[i] ?? [];
const f = fmtOf(s);
const lo = s.min ?? Math.min(...s.values);
const hi = s.max ?? Math.max(...s.values);
const w = Math.max(120, (width - (cols - 1)) / cols - 40);
const h = 56;
const X = (k: number) => round(k * step(w));
const Y = linear(lo, hi, h - 3, 3);
const last = b[b.length - 1] ?? 0;
const prev = b[b.length - 2] ?? last;
return (
<li key={s.label} className="bg-card px-5 pb-4 pt-4">
<p className="flex items-center justify-between gap-2 text-[13px] text-muted-foreground">
<span className="truncate">{s.label}</span>
<span className={cn('inline-flex items-center text-[12px] tabular-nums', last > prev ? 'text-foreground' : 'text-muted-foreground')}>
<Icon name={last >= prev ? 'arrow-up-right' : 'arrow-down-right'} size={13} strokeWidth={2} />
</span>
</p>
<p className="mt-1 text-[26px] font-semibold leading-none tracking-[-0.02em] text-foreground tabular-nums">{f(last)}</p>
<svg width="100%" height={h} viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" aria-hidden className="mt-3 block overflow-hidden">
<g key={tick} className={shiftStyle(step(w)) ? 'kitlive-shift' : undefined} style={shiftStyle(step(w))}>
<path d={`${linePath(b.map((v, k) => [X(k + (win - b.length)), Y(v)]))}L${X(win - 1)} ${h}L${X(win - b.length)} ${h}Z`} fill={pairColor(i)} fillOpacity="0.1" />
<path d={linePath(b.map((v, k) => [X(k + (win - b.length)), Y(v)]))} fill="none" stroke={pairColor(i)} strokeWidth="2" vectorEffect="non-scaling-stroke" />
</g>
</svg>
</li>
);
})}
{/* Empty cells close the last row, so the hairline grid has no grey gap */}
{Array.from({ length: (cols - (series.length % cols)) % cols }, (_, k) => (
<li key={`fill${k}`} aria-hidden className="bg-card" />
))}
</ul>
</div>
<ChartTable caption={name} head={[t.value, t.latest]} rows={series.map((s, i) => [s.label, fmtOf(s)(bufs[i]?.[bufs[i].length - 1] ?? 0)])} />
</ChartRoot>
</ChartFigure>
);
}
/* ---------------- events ---------------- */
if (variant === 'events') {
const f = numberFormatter(intl, format);
const height = 96;
const left = 8;
const right = width - 8;
const X = (ago: number) => round(right - (ago / 60) * (right - left));
const vmax = Math.max(1, ...evts.map((e) => e.value));
const recent = [...evts].sort((a, b) => a.ago - b.ago).slice(0, phone ? 4 : 5);
const total = evts.reduce((a, e) => a + e.value, 0);
return (
<ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} actions={status} className={className}>
<ChartRoot anim={anim} rootRef={rootRef}>
<style>{CSS}</style>
<div ref={wrapRef} onPointerEnter={(e) => e.pointerType === 'mouse' && setHover(0)} onPointerLeave={() => setHover(null)}>
<ChartSurface label={name} summary={summary ?? `${evts.length} · ${f(total)}`} labels={labels} live="" table={<ChartTable caption={name} head={[t.event, t.value, t.time]} rows={recent.map((e) => [e.label, f(e.value), t.ago.replace('{n}', String(Math.round(e.ago)))])} />} focusable={false}>
<svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden className="block overflow-visible">
<line x1={left} x2={right} y1={height / 2} y2={height / 2} stroke="var(--kitch-axis)" strokeWidth="1" />
{(phone ? [0, 30, 60] : [0, 15, 30, 45, 60]).map((s) => (
<g key={s}>
<line x1={X(s)} x2={X(s)} y1={height / 2 - 4} y2={height / 2 + 4} stroke="var(--kitch-axis)" strokeWidth="1" />
<text x={X(s)} y={height - 4} textAnchor={s === 0 ? 'end' : s === 60 ? 'start' : 'middle'} className="text-[11px] tabular-nums" fill="var(--muted-foreground)">
{s === 0 ? t.now : t.ago.replace('{n}', String(s))}
</text>
</g>
))}
{evts.map((e) => {
const r = round(4 + Math.sqrt(e.value / vmax) * 10);
return (
<g key={e.label} style={{ transform: `translateX(${X(e.ago)}px)`, transition: reduced ? undefined : `transform ${interval}ms linear` }}>
{e.ago < 1.5 && !reduced && <circle cx={0} cy={height / 2} r={r} fill="var(--primary)" className="kitlive-ping" />}
<circle cx={0} cy={height / 2} r={r} fill="var(--primary)" fillOpacity={0.35 + 0.65 * (1 - e.ago / 60)} stroke="var(--kitch-surface)" strokeWidth="2" />
</g>
);
})}
</svg>
</ChartSurface>
<ul aria-live="polite" aria-relevant="additions" className="mt-4 grid divide-y divide-border rounded-lg border border-border">
{recent.map((e) => (
<li key={e.label} className="kitlive-in flex items-center justify-between gap-3 px-3.5 py-2.5 text-[13px]">
<span className="flex min-w-0 items-center gap-2">
<span aria-hidden className="size-2 shrink-0 rounded-full bg-primary" />
<span className="truncate text-foreground">{e.label}</span>
</span>
<span className="flex shrink-0 items-baseline gap-3">
<span className="font-medium tabular-nums text-foreground">{f(e.value)}</span>
<span className="w-16 text-right text-[12px] tabular-nums text-muted-foreground">{e.ago < 1 ? t.now : t.ago.replace('{n}', String(Math.round(e.ago)))}</span>
</span>
</li>
))}
</ul>
<span className="sr-only">{clock}</span>
</div>
</ChartRoot>
</ChartFigure>
);
}
/* ---------------- line, bars, latency ---------------- */
const s0 = series[0];
const f0 = fmtOf(s0);
const lo = Math.min(...series.map((s) => s.min ?? Math.min(...s.values)), limit ?? Infinity);
const hi = Math.max(...series.map((s) => s.max ?? Math.max(...s.values)), limit ?? -Infinity);
const nice = niceTicks(variant === 'bars' ? 0 : lo, hi, 4);
const tk = tickFormatter(intl, s0?.format ?? format);
const yLabels = nice.ticks.map((v) => tk(v) + (s0?.suffix && !/[%]/.test(tk(v)) ? (s0.suffix.trim().length <= 2 ? s0.suffix : '') : ''));
const height = phone ? 220 : 260;
const left = Math.max(...yLabels.map((s) => textWidth(s, 11)), 16) + 14;
const right = width - (phone ? 10 : 64);
const top = 16;
const bottom = height - 26;
const Y = linear(nice.min, nice.max, bottom, top);
const w = right - left;
const X = (k: number) => round(left + k * step(w));
const b0 = bufs[0] ?? [];
const lastV = b0[b0.length - 1] ?? 0;
const pick = (clientX: number, el: HTMLElement) => {
const r = el.getBoundingClientRect();
const px = (clientX - r.left) * (width / (r.width || width));
const k = Math.round((px - left) / step(w)) - (win - b0.length);
setHover(k >= 0 && k < b0.length ? k : null);
};
const onKeyDown = (e: React.KeyboardEvent) => {
const cur = hover ?? b0.length;
let next: number | null = cur;
if (e.key === 'ArrowLeft') next = Math.max(0, cur - 1);
else if (e.key === 'ArrowRight') next = Math.min(b0.length - 1, cur + 1);
else if (e.key === 'Escape') next = null;
else if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
return setPaused((p) => !p);
} else return;
e.preventDefault();
setHover(next);
};
const off = (b: number[]) => win - b.length;
const shift = shiftStyle(step(w));
const hoverVal = hover !== null ? b0[hover] : null;
const legend =
series.length > 1 ? (
<ul className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[12px] text-muted-foreground">
{series.map((s, i) => (
<li key={s.label} className="flex items-center gap-1.5">
<Swatch shape="line" color={pairColor(i)} />
{s.label}
<span className="font-medium tabular-nums text-foreground">{fmtOf(s)((bufs[i] ?? [])[(bufs[i] ?? []).length - 1] ?? 0)}</span>
</li>
))}
{limit !== undefined && (
<li className="flex items-center gap-1.5">
<Swatch shape="line" dashed color="var(--kitfg-down)" />
{limitLabel}
</li>
)}
</ul>
) : null;
return (
<ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} actions={status} className={className}>
<ChartRoot anim={anim} rootRef={rootRef}>
<style>{CSS}</style>
{legend}
<div ref={wrapRef}>
<ChartSurface
label={name}
summary={summary ?? `${s0?.label ?? ''} ${f0(lastV)}`}
labels={labels}
live={hover !== null && hoverVal !== null && hoverVal !== undefined ? `${agoText(hover, b0.length)}. ${f0(hoverVal)}` : ''}
table={<ChartTable caption={name} head={[t.time, ...series.map((s) => s.label)]} rows={b0.slice(-10).map((_, k) => { const idx = b0.length - 10 + k; return [agoText(idx, b0.length), ...series.map((s, i) => fmtOf(s)((bufs[i] ?? [])[idx] ?? 0))]; })} />}
onKeyDown={onKeyDown}
onFocus={(e) => e.target === e.currentTarget && e.currentTarget.matches(':focus-visible') && setHover(b0.length - 1)}
onBlur={() => setHover(null)}
onPointerMove={(e) => pick(e.clientX, e.currentTarget)}
onPointerDown={(e) => pick(e.clientX, e.currentTarget)}
onPointerLeave={(e) => e.pointerType === 'mouse' && setHover(null)}
>
<svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden className="block overflow-visible">
<defs>
<clipPath id={clipId}>
<rect x={left} y={0} width={w + 2} height={height} />
</clipPath>
</defs>
<PlotAxes left={left} right={right} top={top} bottom={bottom} height={height} yTicks={nice.ticks.map((v, i) => ({ at: Y(v), label: yLabels[i], strong: v === 0 }))} />
<text x={right} y={height - 6} textAnchor="end" className="text-[11px]" fill="var(--muted-foreground)">{t.now}</text>
<text x={left} y={height - 6} className="text-[11px] tabular-nums" fill="var(--muted-foreground)">{t.ago.replace('{n}', String(Math.round(((win - 1) * interval) / 1000)))}</text>
{limit !== undefined && (
<g>
<line x1={left} x2={right} y1={Y(limit)} y2={Y(limit)} stroke="var(--kitfg-down)" strokeWidth="1.5" strokeDasharray="5 4" />
</g>
)}
<g clipPath={`url(#${clipId})`}>
<g key={tick} className={shift ? 'kitlive-shift' : undefined} style={shift}>
{variant === 'bars' &&
b0.map((v, k) => {
const bw = Math.max(2, step(w) * 0.62);
const x = X(k + off(b0)) - bw / 2;
return <path key={k} d={barPath(x, Y(v), bw, Y(nice.min) - Y(v), 2, 'up')} fill={k === b0.length - 1 ? 'var(--primary)' : 'color-mix(in oklab, var(--primary) 45%, var(--card))'} opacity={hover !== null && hover !== k ? 0.55 : 1} />;
})}
{variant === 'latency' && series.length > 1 && (
<path d={`${linePath((bufs[1] ?? []).map((v, k) => [X(k + off(bufs[1])), Y(v)]))}L${X(win - 1)} ${Y((bufs[0] ?? [])[(bufs[0] ?? []).length - 1] ?? 0)}${[...(bufs[0] ?? [])].map((v, k) => [X(k + off(bufs[0])), Y(v)] as [number, number]).reverse().map(([x, y]) => `L${x} ${y}`).join('')}Z`} fill="var(--primary)" fillOpacity="0.12" />
)}
{variant !== 'bars' &&
series.map((s, i) => {
const b = bufs[i] ?? [];
return <path key={s.label} d={linePath(b.map((v, k) => [X(k + off(b)), Y(v)]))} fill="none" stroke={variant === 'latency' ? (i === 0 ? 'var(--primary)' : 'var(--kitfg-3)') : pairColor(i)} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />;
})}
{variant === 'line' && <path d={`${linePath(b0.map((v, k) => [X(k + off(b0)), Y(v)]))}L${X(win - 1)} ${bottom}L${X(off(b0))} ${bottom}Z`} fill="var(--primary)" fillOpacity="0.08" />}
</g>
</g>
{/* Newest point, pulsing, and its value at the right edge */}
{variant !== 'bars' && b0.length > 0 && (
<g>
{!hold && !reduced && <circle cx={X(win - 1)} cy={Y(lastV)} r={4.5} fill="var(--primary)" className="kitlive-ping" />}
<circle cx={X(win - 1)} cy={Y(lastV)} r={4.5} fill="var(--primary)" stroke="var(--kitch-surface)" strokeWidth="2" className="transition-[cy] duration-300" />
</g>
)}
{!phone && (
<g style={{ transform: `translateY(${round(Y(lastV))}px)` }} className="transition-transform duration-300">
<rect x={right + 8} y={-10} width={textWidth(f0(lastV), 11) + 12} height={20} rx="4" fill="var(--foreground)" />
<text x={right + 14} y={0} dy="0.34em" className="text-[11px] font-semibold tabular-nums" fill="var(--background)">{f0(lastV)}</text>
</g>
)}
{hover !== null && hoverVal !== undefined && hoverVal !== null && (
<g>
<line x1={X(hover + off(b0))} x2={X(hover + off(b0))} y1={top} y2={bottom} stroke="var(--kitch-axis)" strokeWidth="1" />
<circle cx={X(hover + off(b0))} cy={Y(hoverVal)} r={4.5} fill="var(--primary)" stroke="var(--kitch-surface)" strokeWidth="2" />
<text x={X(hover + off(b0))} y={top - 4} textAnchor={X(hover + off(b0)) > right - 60 ? 'end' : X(hover + off(b0)) < left + 60 ? 'start' : 'middle'} className="text-[11px] font-semibold tabular-nums" fill="var(--foreground)" style={HALO}>
{`${f0(hoverVal)} · ${agoText(hover, b0.length)}`}
</text>
</g>
)}
</svg>
</ChartSurface>
<p className="mt-2 flex min-h-5 items-center gap-1.5 text-[12px] text-muted-foreground">
{hover !== null && !paused ? (
<>
<Icon name="pause" size={13} />
{t.held}
</>
) : variant === 'bars' ? (
`${s0?.label ?? ''} ${t.perSec}`
) : null}
</p>
</div>
</ChartRoot>
</ChartFigure>
);
}Frequent questions
How do I install Live chart?
With aiw add @ai-website/live-chart. The command puts components/ui/live-chart.tsx into your project, the code is yours from then on. It brings along icon, chart and chart-figure. It needs no further npm packages.
Which variants does Live chart have?
5 variants, Sliding line, Bars per second, Event track, Live tiles and Latency band. You pick one with the variant prop, for example variant="bars".
Can I translate the texts of Live chart?
Yes. Live chart carries 12 texts of its own in English and German, such as “Pause updates”, “Resume updates” and “Live”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Live chart?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Live chart with aiw_components and install it with aiw_add @ai-website/live-chart. Name the variant in your prompt, for example variant="line", and the assistant writes your content straight into the props.
Can I adjust Live chart without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching LiveChart code to copy.