Skip to content

Probability Chart

Odds over time per outcome with a snapping crosshair.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/probability-chart.json

Manual

  1. Install the dependencies

    npm install clsx motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/markets/format.ts
    import { FULL } from "@/lib/markets/math";
    
    const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
    // minimumFractionDigits: 0 matters: currency defaults to 2, and Node and browsers clamp it differently ("$412.0K" vs "$412K").
    const usdCompact = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", notation: "compact", minimumFractionDigits: 0, maximumFractionDigits: 1 });
    
    /** "64%" (or "64.2%" with decimals). */
    export const formatProbability = (bps: number, decimals = 0) => `${(bps / 100).toFixed(decimals)}%`;
    
    /** "64¢". */
    export const formatPrice = (bps: number) => `${Math.round(bps / 100)}¢`;
    
    /** "$1,234.56", or "$1.2M" compact. Input in cents. */
    export const formatUsd = (cents: number, compact = false) => (compact ? usdCompact.format(cents / 100) : usd.format(cents / 100));
    
    /** Shares from hundredths: "156.25". */
    export const formatShares = (hundredths: number) => (hundredths / 100).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
    
    /** "▲ 4%" / "▼ 2.5%" from a basis-point change (never color alone: the arrow carries the direction). */
    export function formatChange(bps: number) {
      const value = Math.abs(bps) / 100;
      return `${bps >= 0 ? "▲" : "▼"} ${value % 1 === 0 ? value.toFixed(0) : value.toFixed(1)}%`;
    }
    
    /** Signed percent from basis points: "+56%". */
    export const formatReturn = (bps: number) => `${bps >= 0 ? "+" : "−"}${(Math.abs(bps) / 100).toFixed(bps % 100 === 0 ? 0 : 1)}%`;
    
    /** "Ends in 3d 4h", "Ends in 42m" or "Ended". */
    export function timeLeft(endsAt: number, now: number) {
      const ms = endsAt - now;
      if (ms <= 0) return "Ended";
      const minutes = Math.floor(ms / 60000);
      const days = Math.floor(minutes / 1440);
      const hours = Math.floor((minutes % 1440) / 60);
      if (days > 0) return `Ends in ${days}d ${hours}h`;
      if (hours > 0) return `Ends in ${hours}h ${minutes % 60}m`;
      return `Ends in ${Math.max(1, minutes)}m`;
    }
    
    /** A fixed, locale-stable date: "Mar 14, 2027". */
    export const formatDate = (ms: number) => new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }).format(ms);
    
    export { FULL };
    lib/markets/math.ts
    /**
     * Prediction market math on integers only: prices in basis points (10000 = $1.00 = 100%), money in
     * cents and shares in hundredths. A share pays $1 (100 cents) if its outcome wins.
     */
    export const FULL = 10000;
    
    const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
    
    /** The other side of a binary price: Yes + No = 100¢. */
    export const complement = (price: number) => FULL - price;
    
    /** Shares (in hundredths) that `amount` cents buys at `price` basis points. */
    export const sharesFor = (amount: number, price: number) => (price > 0 ? Math.floor((amount * FULL) / price) : 0);
    
    /** Payout in cents if those shares win: each share pays 100 cents, so hundredths of a share map 1:1 to cents. */
    export const payoutFor = (shares: number) => shares;
    
    /** Value in cents of shares (hundredths) at a price. */
    export const valueOf = (shares: number, price: number) => Math.floor((shares * price) / FULL);
    
    export interface BuyQuote {
      /** Average fill price, basis points. */
      averagePrice: number;
      /** Price after the trade, basis points. */
      newPrice: number;
      /** Price impact, basis points (newPrice − price). */
      impact: number;
      /** Shares received, hundredths. */
      shares: number;
      /** Payout if it wins, cents. */
      payout: number;
      /** Return if it wins, basis points of the amount (5600 = +56%). */
      returnBps: number;
    }
    
    /**
     * A simulated buy against a constant-product style curve: the more of the pool you take, the further
     * the price moves toward $1. Good enough to show impact and average price in a demo.
     */
    export function quoteBuy(price: number, amount: number, liquidity: number): BuyQuote {
      const p = clamp(Math.round(price), 1, FULL - 1);
      if (amount <= 0) return { averagePrice: p, newPrice: p, impact: 0, shares: 0, payout: 0, returnBps: 0 };
      const take = Math.floor((amount * FULL) / (amount + Math.max(1, liquidity)));
      const newPrice = clamp(p + Math.floor(((FULL - p) * take) / FULL), 1, FULL - 1);
      const averagePrice = Math.floor((p + newPrice) / 2);
      const shares = sharesFor(amount, averagePrice);
      const payout = payoutFor(shares);
      return { averagePrice, newPrice, impact: newPrice - p, shares, payout, returnBps: Math.floor(((payout - amount) * FULL) / amount) };
    }
    
    /** A simulated limit buy: fills entirely at the limit price, no impact. */
    export function quoteLimit(limitPrice: number, amount: number): BuyQuote {
      const p = clamp(Math.round(limitPrice), 1, FULL - 1);
      const shares = sharesFor(Math.max(0, amount), p);
      return { averagePrice: p, newPrice: p, impact: 0, shares, payout: shares, returnBps: amount > 0 ? Math.floor(((shares - amount) * FULL) / amount) : 0 };
    }
    
    /** Proceeds in cents from selling shares (hundredths), with the price sliding toward 0 by the same curve. */
    export function quoteSell(price: number, shares: number, liquidity: number) {
      const p = clamp(Math.round(price), 1, FULL - 1);
      const gross = valueOf(shares, p);
      const take = Math.floor((gross * FULL) / (gross + Math.max(1, liquidity)));
      const newPrice = clamp(p - Math.floor((p * take) / FULL), 1, FULL - 1);
      const averagePrice = Math.floor((p + newPrice) / 2);
      return { proceeds: valueOf(shares, averagePrice), averagePrice, newPrice, impact: newPrice - p };
    }
    
    /** Profit and loss in cents for shares bought at an average price, marked at the current price. */
    export function profitAndLoss(shares: number, averagePrice: number, currentPrice: number) {
      const cost = valueOf(shares, averagePrice);
      const value = valueOf(shares, currentPrice);
      return { cost, value, pnl: value - cost, pnlBps: cost ? Math.floor(((value - cost) * FULL) / cost) : 0 };
    }
    
    /** Scales positive weights so they sum to exactly 10000 (rounding error goes to the largest). */
    export function normalize(prices: number[]) {
      const total = prices.reduce((sum, p) => sum + Math.max(0, p), 0) || 1;
      const scaled = prices.map((p) => Math.floor((Math.max(0, p) * FULL) / total));
      const rest = FULL - scaled.reduce((sum, p) => sum + p, 0);
      const largest = scaled.indexOf(Math.max(...scaled));
      if (largest >= 0) scaled[largest] += rest;
      return scaled;
    }
    lib/markets/types.ts
    /** Prices and probabilities are integer basis points (0–10000: 6400 = 64% = 64¢), money is integer cents. */
    export type MarketStatus = "open" | "closing" | "resolved";
    
    export interface MarketOutcome {
      id: string;
      name: string;
      /** Price in basis points (0–10000). Binary markets: Yes + No always sum to 10000. */
      price: number;
      /** 24h change in basis points (positive = up). */
      change24h?: number;
      /** Avatar or icon image for the outcome. */
      image?: string;
      /** Volume traded on this outcome, in cents. */
      volume?: number;
    }
    
    export interface PricePoint {
      /** Timestamp, ms. */
      t: number;
      /** Price per outcome, in basis points, same order as outcomes. */
      prices: number[];
    }
    
    export interface Market {
      id: string;
      question: string;
      /** Topic label, e.g. "Tech". */
      topic: string;
      /** Market image URL; a generated icon is used without one. */
      image?: string;
      type: "binary" | "multi";
      /** Binary markets: [Yes, No]. Multi-outcome: probabilities normalized to 10000. */
      outcomes: MarketOutcome[];
      /** Total volume, cents. */
      volume: number;
      /** Simulated liquidity used for price impact, cents. */
      liquidity: number;
      traders: number;
      /** Names of a few traders, for the avatar group. */
      traderNames?: string[];
      /** End time, ms since epoch. */
      endsAt: number;
      status: MarketStatus;
      /** Winning outcome once resolved. */
      resolvedOutcomeId?: string;
      comments?: number;
      history?: PricePoint[];
      rules?: string;
      resolutionSource?: { label: string; href: string };
    }
    
    export type TradeSide = "buy" | "sell";
    lib/markets/use-now.ts
    import { useSyncExternalStore } from "react";
    
    let now = 0;
    const listeners = new Set<() => void>();
    let timer = 0;
    
    function subscribe(listener: () => void) {
      listeners.add(listener);
      // Refresh on every subscribe, so a component that mounts while the timer is already running
      // does not start with a timestamp up to 15 s old.
      now = Date.now();
      if (!timer) {
        timer = window.setInterval(() => {
          now = Date.now();
          listeners.forEach((l) => l());
        }, 15000);
      }
      return () => {
        listeners.delete(listener);
        if (!listeners.size) {
          window.clearInterval(timer);
          timer = 0;
        }
      };
    }
    
    /**
     * The current time, refreshed every 15 s. It is 0 on the server and during hydration, so countdowns
     * render a placeholder first and never mismatch.
     */
    export function useNow() {
      return useSyncExternalStore(
        subscribe,
        () => now || Date.now(),
        () => 0,
      );
    }
    
    const noop = () => () => {};
    
    /**
     * False on the server and during hydration, true after. Gate text derived from timestamps (axis
     * labels, dates) with it when the data may be generated relative to page load.
     */
    export function useHydrated() {
      return useSyncExternalStore(
        noop,
        () => true,
        () => false,
      );
    }
  3. Copy the source

    components/ui/probability-chart/probability-chart.tsx
    "use client";
    
    import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
    import { motion, useReducedMotion } from "motion/react";
    import { cn } from "@/lib/utils";
    import { formatProbability } from "@/lib/markets/format";
    import type { Market } from "@/lib/markets/types";
    import { useHydrated } from "@/lib/markets/use-now";
    
    export type ChartTimeframe = "1H" | "6H" | "1D" | "1W" | "1M" | "ALL";
    
    export interface ChartAnnotation {
      /** Timestamp, ms. */
      t: number;
      /** Short label, e.g. "News: launch delayed". */
      label: string;
    }
    
    export interface ProbabilityChartProps {
      /** Market with `history` (prices per outcome in basis points). */
      market: Market;
      /** Initial timeframe. @default "1W" */
      defaultTimeframe?: ChartTimeframe;
      /** Event dots on the first line, with a hover card. */
      annotations?: ChartAnnotation[];
      /** Plot height in px. @default 220 */
      height?: number;
      /** Classes for the root. */
      className?: string;
    }
    
    const FRAMES: { id: ChartTimeframe; ms: number }[] = [
      { id: "1H", ms: 3600_000 },
      { id: "6H", ms: 6 * 3600_000 },
      { id: "1D", ms: 86400_000 },
      { id: "1W", ms: 7 * 86400_000 },
      { id: "1M", ms: 30 * 86400_000 },
      { id: "ALL", ms: Infinity },
    ];
    const COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)"];
    const POINTS = 48;
    const PAD = { top: 10, right: 44, bottom: 22, left: 8 };
    
    const timeFormat = (span: number) =>
      new Intl.DateTimeFormat("en-US", span <= 86400_000 ? { hour: "numeric", minute: "2-digit", timeZone: "UTC" } : { month: "short", day: "numeric", timeZone: "UTC" });
    
    /** Resamples history to POINTS evenly spaced points in [start, end] so paths can morph between timeframes. */
    function resample(history: NonNullable<Market["history"]>, series: number, start: number, end: number) {
      const out: { t: number; values: number[] }[] = [];
      for (let i = 0; i < POINTS; i++) {
        const t = start + ((end - start) * i) / (POINTS - 1);
        let k = history.findIndex((point) => point.t >= t);
        if (k <= 0) k = Math.max(1, k === -1 ? history.length - 1 : 1);
        const a = history[k - 1];
        const b = history[k];
        const f = b.t === a.t ? 1 : Math.min(1, Math.max(0, (t - a.t) / (b.t - a.t)));
        out.push({ t, values: Array.from({ length: series }, (_, s) => Math.round(a.prices[s] + (b.prices[s] - a.prices[s]) * f)) });
      }
      return out;
    }
    
    /**
     * Probability over time, one line per outcome, with timeframe tabs that morph the lines, a legend that
     * toggles them, a snapping crosshair, live ticks and optional event annotations.
     */
    export function ProbabilityChart({ market, defaultTimeframe = "1W", annotations = [], height = 220, className }: ProbabilityChartProps) {
      const id = useId();
      const reduceMotion = useReducedMotion();
      const box = useRef<HTMLDivElement>(null);
      const hydrated = useHydrated();
      const [width, setWidth] = useState(600);
      const [frame, setFrame] = useState<ChartTimeframe>(defaultTimeframe);
      const [hidden, setHidden] = useState<Record<number, boolean>>({});
      const [active, setActive] = useState<number | null>(null);
      const [note, setNote] = useState<number | null>(null);
      const series = Math.min(market.type === "binary" ? 1 : market.outcomes.length, COLORS.length);
    
      useEffect(() => {
        const el = box.current;
        if (!el) return;
        const observer = new ResizeObserver(([entry]) => setWidth(Math.max(240, Math.round(entry.contentRect.width))));
        observer.observe(el);
        return () => observer.disconnect();
      }, []);
    
      const history = useMemo(() => market.history ?? [], [market.history]);
      const end = history.length ? history[history.length - 1].t : 0;
      const span = FRAMES.find((f) => f.id === frame)!.ms;
      const start = history.length ? Math.max(history[0].t, span === Infinity ? history[0].t : end - span) : 0;
      const points = useMemo(() => (history.length > 1 ? resample(history, series, start, end) : []), [history, series, start, end]);
      const plotW = width - PAD.left - PAD.right;
      const plotH = height - PAD.top - PAD.bottom;
      const x = (i: number) => PAD.left + (i / (POINTS - 1)) * plotW;
      const y = (bps: number) => PAD.top + (1 - bps / 10000) * plotH;
      const fmt = timeFormat(end - start);
      const path = (s: number) => points.map((p, i) => `${i ? "L" : "M"}${x(i).toFixed(1)} ${y(p.values[s]).toFixed(1)}`).join(" ");
      const last = points[points.length - 1];
      const shown = active !== null ? points[active] : null;
    
      const pick = (event: PointerEvent<SVGSVGElement>) => {
        const rect = event.currentTarget.getBoundingClientRect();
        const i = Math.round(((event.clientX - rect.left - PAD.left) / plotW) * (POINTS - 1));
        setActive(Math.min(POINTS - 1, Math.max(0, i)));
      };
      const onKeyDown = (event: KeyboardEvent<SVGSVGElement>) => {
        if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
          event.preventDefault();
          setActive((current) => Math.min(POINTS - 1, Math.max(0, (current ?? POINTS - 1) + (event.key === "ArrowRight" ? 1 : -1))));
        } else if (event.key === "Home" || event.key === "End") {
          event.preventDefault();
          setActive(event.key === "Home" ? 0 : POINTS - 1);
        } else if (event.key === "Escape") setActive(null);
      };
      const describe = (p: { t: number; values: number[] }) =>
        `${fmt.format(p.t)}: ${Array.from({ length: series }, (_, s) => `${market.type === "binary" ? "Yes" : market.outcomes[s].name} ${formatProbability(p.values[s])}`).join(", ")}`;
    
      return (
        <div className={cn("w-full", className)}>
          <div className="flex flex-wrap items-center justify-between gap-2">
            <ul className="flex flex-wrap gap-1.5" aria-label="Lines">
              {Array.from({ length: series }, (_, s) => {
                const name = market.type === "binary" ? "Yes" : market.outcomes[s].name;
                return (
                  <li key={name}>
                    <button
                      type="button"
                      aria-pressed={!hidden[s]}
                      onClick={() => setHidden((h) => ({ ...h, [s]: !h[s] }))}
                      className={cn("flex h-7 cursor-pointer items-center gap-1.5 rounded-full border border-border px-2.5 text-[12px] outline-none focus-visible:ring-2 focus-visible:ring-ring", hidden[s] ? "text-muted-foreground opacity-60" : "text-foreground")}
                    >
                      <span aria-hidden="true" className="size-2 rounded-full" style={{ background: COLORS[s] }} />
                      {name}
                      {last ? <span className="font-medium tabular-nums">{formatProbability(last.values[s])}</span> : null}
                    </button>
                  </li>
                );
              })}
            </ul>
            <div role="tablist" aria-label="Timeframe" className="flex rounded-full bg-surface-2 p-0.5">
              {FRAMES.map((f) => (
                <button key={f.id} type="button" role="tab" aria-selected={frame === f.id} onClick={() => setFrame(f.id)} className={cn("relative h-7 cursor-pointer rounded-full px-2.5 text-[12px] font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", frame === f.id ? "text-foreground" : "text-[color-mix(in_oklab,var(--muted-foreground)_75%,var(--foreground))]")}>
                  {frame === f.id ? <motion.span layoutId={`${id}-frame`} transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 34 }} className="absolute inset-0 rounded-full bg-background shadow-sm" /> : null}
                  <span className="relative">{f.id}</span>
                </button>
              ))}
            </div>
          </div>
          <div ref={box} className="relative mt-3">
            <svg
              width={width}
              height={height}
              role="img"
              aria-label={`Probability chart, ${frame}. ${last ? describe(last) : ""}. Use the arrow keys to read values.`}
              tabIndex={0}
              onPointerMove={pick}
              onPointerLeave={() => setActive(null)}
              onKeyDown={onKeyDown}
              onBlur={() => setActive(null)}
              className="block touch-pan-y overflow-visible rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              {[0, 2500, 5000, 7500, 10000].map((g) => (
                <g key={g}>
                  <line x1={PAD.left} x2={PAD.left + plotW} y1={y(g)} y2={y(g)} stroke="var(--border)" strokeDasharray={g === 0 ? undefined : "2 4"} />
                  <text x={PAD.left + plotW + 6} y={y(g) + 4} fontSize="11" fill="var(--muted-foreground)">
                    {g / 100}%
                  </text>
                </g>
              ))}
              {points.length ? (
                <>
                  <text x={PAD.left} y={height - 4} fontSize="11" fill="var(--muted-foreground)">
                    {hydrated ? fmt.format(points[0].t) : null}
                  </text>
                  <text x={PAD.left + plotW} y={height - 4} fontSize="11" fill="var(--muted-foreground)" textAnchor="end">
                    {hydrated ? fmt.format(end) : null}
                  </text>
                </>
              ) : null}
              {Array.from({ length: series }, (_, s) =>
                hidden[s] || !points.length ? null : (
                  <motion.path key={s} d={path(s)} initial={false} animate={{ d: path(s) }} transition={reduceMotion ? { duration: 0 } : { duration: 0.5, ease: [0.22, 1, 0.36, 1] }} fill="none" stroke={COLORS[s]} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
                ),
              )}
              {last
                ? Array.from({ length: series }, (_, s) =>
                    hidden[s] ? null : (
                      <g key={`end-${s}`}>
                        <circle cx={x(POINTS - 1)} cy={y(last.values[s])} r="7" fill={COLORS[s]} opacity="0.25" className="origin-center animate-ping [transform-box:fill-box] motion-reduce:animate-none" />
                        <circle cx={x(POINTS - 1)} cy={y(last.values[s])} r="3.5" fill={COLORS[s]} stroke="var(--background)" strokeWidth="2" />
                      </g>
                    ),
                  )
                : null}
              {annotations.map((a, k) => {
                if (a.t < start || a.t > end || !points.length) return null;
                const i = Math.round(((a.t - start) / Math.max(1, end - start)) * (POINTS - 1));
                return (
                  <circle
                    key={a.t}
                    cx={x(i)}
                    cy={y(points[i].values[0])}
                    r="5"
                    fill="var(--background)"
                    stroke="var(--foreground)"
                    strokeWidth="2"
                    onPointerEnter={() => setNote(k)}
                    onPointerLeave={() => setNote(null)}
                    className="cursor-help"
                  />
                );
              })}
              {shown && active !== null ? (
                <g pointerEvents="none">
                  <line x1={x(active)} x2={x(active)} y1={PAD.top} y2={PAD.top + plotH} stroke="var(--foreground)" strokeOpacity="0.3" />
                  {Array.from({ length: series }, (_, s) => (hidden[s] ? null : <circle key={s} cx={x(active)} cy={y(shown.values[s])} r="4" fill={COLORS[s]} stroke="var(--background)" strokeWidth="2" />))}
                </g>
              ) : null}
            </svg>
            {shown && active !== null ? (
              <div
                aria-hidden="true"
                className="pointer-events-none absolute top-0 z-10 rounded-lg border border-border bg-background px-2.5 py-1.5 text-[12px] shadow-md"
                style={{ left: Math.min(Math.max(0, x(active) + 10), width - 150) }}
              >
                <p className="text-muted-foreground">{fmt.format(shown.t)}</p>
                {Array.from({ length: series }, (_, s) =>
                  hidden[s] ? null : (
                    <p key={s} className="flex items-center gap-1.5">
                      <span className="size-1.5 rounded-full" style={{ background: COLORS[s] }} />
                      {market.type === "binary" ? "Yes" : market.outcomes[s].name}
                      <span className="ml-auto pl-3 font-medium tabular-nums">{formatProbability(shown.values[s])}</span>
                    </p>
                  ),
                )}
              </div>
            ) : null}
            {note !== null && annotations[note] ? (
              <div role="tooltip" className="pointer-events-none absolute top-8 left-1/2 z-10 -translate-x-1/2 rounded-lg bg-foreground px-2.5 py-1.5 text-[12px] text-background shadow-md">
                {annotations[note].label}
              </div>
            ) : null}
          </div>
          <p aria-live="polite" className="sr-only">
            {shown ? describe(shown) : ""}
          </p>
          <table className="sr-only">
            <caption>Probability by time</caption>
            <thead>
              <tr>
                <th scope="col">Time</th>
                {Array.from({ length: series }, (_, s) => (
                  <th key={s} scope="col">
                    {market.type === "binary" ? "Yes" : market.outcomes[s].name}
                  </th>
                ))}
              </tr>
            </thead>
            <tbody>
              {points.filter((_, i) => i % 6 === 0 || i === POINTS - 1).map((p) => (
                <tr key={p.t}>
                  <th scope="row">{hydrated ? fmt.format(p.t) : null}</th>
                  {p.values.map((v, s) => (
                    <td key={s}>{formatProbability(v)}</td>
                  ))}
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      );
    }

Usage

import { ProbabilityChart } from "@/components/ui/probability-chart/probability-chart";

<ProbabilityChart
  market={market} // market.history: [{ t, prices: [bps, ...] }]
  defaultTimeframe="1W"
  annotations={[{ t: Date.UTC(2026, 9, 12), label: "Supplier report" }]}
/>

API reference

PropTypeDefaultDescription
market*Market–Market with `history` (prices per outcome in basis points).
defaultTimeframeChartTimeframe"1W"Initial timeframe.
annotationsChartAnnotation[]–Event dots on the first line, with a hover card.
heightnumber220Plot height in px.
classNamestring–Classes for the root.
  • Lines are resampled to a fixed number of points, so switching timeframe morphs the path instead of redrawing it.
  • Prices are basis points. Binary markets draw the Yes line; multi-outcome markets draw up to five outcomes using the --chart-1…5 tokens.
  • Date labels render after hydration, because demo data is generated relative to page load.

Accessibility

  • Timeframes are a tab list and the legend entries are toggle buttons (aria-pressed), so every series can be hidden or shown from the keyboard.
  • The plot is focusable: ←/→ move the crosshair point by point, Home/End jump to the ends and Escape clears it. The value under the crosshair is announced politely.
  • A visually hidden table lists the sampled values, so the data is readable without the chart. Series are named in the legend and tooltip, never by color alone.
  • With reduced motion the line morph and the pulsing live point are skipped.