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.jsonUsage
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.
| Prop | Type | Description |
|---|---|---|
| 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.
| Prop | Type | Description |
|---|---|---|
| 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.
| Prop | Type | Description |
|---|---|---|
| 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.