Skip to content

Living Charts

Live data arrives in steps. A server sends a number every second or so, and if the chart draws each one the moment it lands, the whole line jumps.

A living chart never teleports. Each new value eases in over a few hundred milliseconds, so the chart moves the way the data feels like it should.

Wrong

Requests per second

800

Instant
Right

Requests per second

800

900ms ease-out

Both cards get the same value every 1.5 seconds. The left one redraws on the spot: every point hops one slot to the left and the counter flips to a new number. The right one spends 900ms on the same change with an ease-out curve. The line slides left, the new point comes in from the right edge, and the dot and the counter ride up or down the new segment with it.

Animate the change, not the data. The tween finishes well before the next value arrives, so at every update both cards agree.

The number is part of the chart

A counter that flips from 620 to 790 reads as two separate facts. One that counts up reads as a change, which is what it is. Use tabular-nums so the digits don't shift sideways while they count.

Ranked lists

When live values change order, the reorder is the interesting part. A snapped list swaps rows between frames, and you have to read the labels again to work out what moved.

Visits by source

Google4,210
GitHub2,860
X1,940
YouTube1,720
Reddit1,180

With animation on, each row slides to its new place over 600ms while its bar and number ease to the new value. You can follow a single source as it climbs or falls without reading anything.

Usage

The hook eases a progress value from 0 to 1 every time a new point arrives. Everything else is derived from it.

app.tsx
import { useEffect, useState } from "react";

// Eases from 0 to 1 over `duration` ms every time `key` changes.
export function useProgress(key: number, duration = 900) {
  const [state, setState] = useState({ key, t: 1 });

  useEffect(() => {
    const start = performance.now();
    let frame = 0;
    const step = (now: number) => {
      const p = Math.min(1, (now - start) / duration);
      setState({ key, t: 1 - (1 - p) ** 3 }); // ease-out
      if (p < 1) frame = requestAnimationFrame(step);
    };
    frame = requestAnimationFrame(step);
    return () => cancelAnimationFrame(frame);
  }, [key, duration]);

  // A new key starts at 0 before the first frame runs.
  return state.key === key ? state.t : 0;
}

// values keeps one extra point past the left edge, clipped away.
const t = useProgress(tick);
const x = (i: number) => (i - t) * slot;
const head = previous + (latest - previous) * t;

Keep the y axis fixed to a known range while data streams in, or the chart rescales on every update and undoes the calm. When prefers-reduced-motion is set, return 1 from the hook and the chart steps like the left card.

Resources

Search concepts

Search for a command to run...