TrackerFree

One block per period coloured by status, with the counts in words; missing periods are shown as missing. Plain HTML: no chart engine.

npx shadcn@latest add https://beautifulcharts.dev/r/tracker.json

Usage

A complete client module. Install the item first, then paste it into your app.

"use client";

import { Tracker } from "@/components/beautiful-charts/themes/instrument/tracker";

const days = [
  { day: "Sep 26", status: "up" }, { day: "Sep 27", status: "up" }, { day: "Sep 28", status: "down", detail: "22 min down" },
  { day: "Sep 29", status: "degraded", detail: "Slow responses" }, { day: "Sep 30", status: null }, { day: "Oct 1", status: "up" },
];

// A day with no status is drawn as missing and counted as missing, never as up.
export function ApiUptime() {
  return <Tracker data={days} nameKey="day" statusKey="status" detailKey="detail" label="API uptime" />;
}

Props

  • data*

    TDatum[]

    One row per period, oldest first.

  • nameKey*

    keyof TDatum & string

    The period's name, e.g. a date.

  • statusKey*

    keyof TDatum & string

    The period's status: a key of statuses. Anything else, or nothing, is missing.

  • detailKey

    keyof TDatum & string

    Optional detail for each period, shown on hover, e.g. "12 min down".

  • statuses

    Record<string, TrackerStatus>

    The statuses and how each reads. Default: up (good), degraded (warn), down (bad).

  • label

    string

    What it tracks, e.g. "API uptime". Names it for screen readers.

Sourcecomponents/beautiful-charts/themes/instrument/tracker.tsx

Show the source (71 lines)
"use client";

import { useEffect, useState, type CSSProperties } from "react";
import { useTrackerModel, type TrackerProps } from "../../core/tracker";
import { InstrumentStyles } from "./styles";

export type TrackerChartProps<TDatum extends Record<string, unknown>> = TrackerProps<TDatum> & {
  /** Show the counts under the blocks (default true). */
  showCounts?: boolean | undefined;
  /** Classes on the tracker. */
  className?: string | undefined;
};

const DAYS = ["Sep 3", "Sep 4", "Sep 5", "Sep 6", "Sep 7", "Sep 8", "Sep 9", "Sep 10", "Sep 11", "Sep 12", "Sep 13", "Sep 14", "Sep 15", "Sep 16", "Sep 17",
  "Sep 18", "Sep 19", "Sep 20", "Sep 21", "Sep 22", "Sep 23", "Sep 24", "Sep 25", "Sep 26", "Sep 27", "Sep 28", "Sep 29", "Sep 30", "Oct 1", "Oct 2"];
/** Sample data used when you install the tracker; replace it with your own rows. */
export const trackerSample = DAYS.map((day, i) => ({
  day,
  status: i === 11 ? "down" : i === 12 || i === 23 ? "degraded" : i === 19 ? null : "up",
  detail: i === 11 ? "38 min down" : i === 12 || i === 23 ? "Slow responses" : null,
}));

/**
 * Tracker, Instrument theme. Plain HTML: no chart engine.
 * One block per period, coloured by status, with the counts in words. A period with no status is drawn as missing,
 * never as healthy, and the periods that weren't fine are listed in words under the blocks. It isn't interactive; screen
 * readers get a group named by one sentence (every count and the latest status). Every period, with its status and
 * detail, is in a disclosure under the blocks.
 */
export function Tracker<TDatum extends Record<string, unknown>>(props: TrackerChartProps<TDatum>) {
  const m = useTrackerModel(props);
  const [entered, setEntered] = useState(false);
  // Entered on the next frame (not synchronously in the effect), so the entry animation has a first frame to start from.
  useEffect(() => { const frame = requestAnimationFrame(() => setEntered(true)); return () => cancelAnimationFrame(frame); }, []);
  const showCounts = props.showCounts ?? true;
  // Blocks share the width, so any number of periods fits. The gaps never take more than half the width between them,
  // and close up as the periods get dense.
  const n = m.blocks.length, gap = n > 90 ? 0 : n > 45 ? 1 : 3;
  const columnGap = n > 1 && gap ? `min(${gap}px, calc(50% / ${n - 1}))` : "0px";
  return (
    <div data-slot="tracker" data-bc-theme={"Instrument".toLowerCase()} data-entered={entered && m.k > 0 ? "" : undefined} className={props.className}
      role="group" aria-label={m.summary} style={{ ["--bc-k" as string]: m.k } as CSSProperties}>
      <InstrumentStyles />
      <div data-slot="tracker-blocks" aria-hidden="true" style={{ gridTemplateColumns: `repeat(${Math.max(1, n)}, minmax(0, 1fr))`, columnGap } as CSSProperties}>
        {m.blocks.map((b, i) => (
          <span key={b.key} data-slot="tracker-block" data-tone={b.tone} title={[b.name, b.label, b.detail].filter(Boolean).join(" · ")} style={{ ["--c" as string]: i } as CSSProperties} />
        ))}
      </div>
      {/* Every period in order, with its status and detail: one disclosure, for keyboard, touch and screen readers. */}
      {n ? (
        <details data-slot="tracker-details">
          <summary>{`Every ${m.unit}, in order`}</summary>
          <ol>{m.blocks.map((b) => <li key={b.key} data-tone={b.tone}>{[b.name, b.label, b.detail].filter(Boolean).join(": ")}</li>)}</ol>
        </details>
      ) : null}
      {showCounts ? (
        <p data-slot="tracker-counts" aria-hidden="true">
          {m.counts.filter((c) => c.count).map((c) => <span key={c.key} data-tone={c.tone}>{c.count} {c.label}</span>)}
          {m.missing ? <span data-tone="missing">{m.missing} no data</span> : null}
        </p>
      ) : null}
      {m.incidents.length ? (
        <p data-slot="tracker-incidents" aria-hidden="true">
          {m.incidents.map((b) => <span key={b.key} data-tone={b.tone}>{b.label} {b.name}{b.detail ? ` (${b.detail})` : ""}</span>)}
        </p>
      ) : null}
      {!m.blocks.length ? <p data-slot="tracker-note">No data yet.</p> : null}
    </div>
  );
}

Details

The other 4 tracker props
  • unit

    string

    What one period is, singular, for the summary (default "day").

  • motion

    MotionPreference

    "full" (default), "subtle" or "off". Reduced-motion settings always win.

  • showCounts

    boolean

    Show the counts under the blocks (default true).

  • className

    string

    Classes on the tracker.

Props every chart shares (2): locale, timeZone
  • locale

    string

    BCP 47 locale. Defaults to "en-US" so server and client render the same text.

  • timeZone

    string

    IANA time zone for dates. Defaults to "UTC" so server and client agree.

Pointer
Hovering a block shows its period, status and detail; the periods that weren't fine are written out under the blocks, and every period is in the disclosure.
Keyboard
One tab stop: the disclosure that lists every period with its status and detail.
Screen readers
A group named by one sentence (how many periods, the count of each status, how many have no data, the latest status), and a disclosure listing every period in order with its status and detail.
Motion
The blocks fade in left to right. With reduced motion it appears settled at once.