Skip to content
UI Kit
Accent color

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

5 variants

Variants

Sliding line

One measure slides left every second, the newest point pulses and carries its value on the axis.

Open in Studio

Bars per second

One bar per second, the newest in the accent, older bars step back and slide out on the left.

Open in Studio

Event track

Single events as dots on a running track of the last minute, their size the amount, the newest in a short feed below.

Open in Studio

Live tiles

Several measures as tiles in a hairline grid, each with its current value, its direction and a small sliding line.

Open in Studio

Latency band

The typical and the slow response (p50 and p95) as a band, the limit as a dashed line, current values in the legend.

Open in Studio

Details

What Live chart brings along, read from the registry and the source.

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

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.

  • labeloptional
    string

    Accessible name of the chart. Default: the title.

  • variantoptional
    LiveVariant

    linebarseventsmultilatency

  • seriesoptional
    LiveSeries[]

    line, bars: one series. multi: several. latency: two (typical and slow).

  • eventsoptional
    LiveEvent[]

    events: the events so far

  • eventNamesoptional
    string[]

    events: names the simulation draws new events from, "#" becomes a running number

  • limitoptional
    number

    latency: the limit line

  • limitLabeloptional
    string
  • windowoptional
    number

    Points on screen. Default 40.

  • intervaloptional
    number

    Milliseconds between ticks. Default 1000.

  • simulateoptional
    boolean

    Invent new values inside min and max (demos, previews)

  • formatoptional
    Intl.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
pauseEnglish Pause updatesGerman Aktualisierung anhalten
playEnglish Resume updatesGerman Aktualisierung fortsetzen
liveEnglish LiveGerman Live
pausedEnglish PausedGerman Angehalten
heldEnglish Held while you readGerman Angehalten, solange du liest
agoEnglish {n} s agoGerman vor {n} s
nowEnglish nowGerman jetzt
latestEnglish LatestGerman Zuletzt
timeEnglish TimeGerman Zeit
valueEnglish ValueGerman Wert
eventEnglish EventGerman Ereignis
perSecEnglish per secondGerman 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
components/ui/live-chart.tsx
'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.

Often searched

Jump to

Copied to the clipboard