Uptime Matrix

beta

A day-by-day status history drawn in dithered tiles — operational, degraded, outage — with a keyboard-navigable readout and legend.

Apr 16: Operational, 100% uptime
Apr 17: Operational, 100% uptime
Apr 18: Operational, 100% uptime
Apr 19: Operational, 100% uptime
Apr 20: Operational, 100% uptime
Apr 21: Operational, 100% uptime
Apr 22: Operational, 100% uptime
Apr 23: Operational, 100% uptime
Apr 24: Operational, 100% uptime
Apr 25: Operational, 100% uptime
Apr 26: Operational, 100% uptime
Apr 27: Operational, 100% uptime
Apr 28: Degraded, 99.1% uptime. Elevated latency
Apr 29: Operational, 100% uptime
Apr 30: Operational, 100% uptime
May 1: Operational, 100% uptime
May 2: Operational, 100% uptime
May 3: Operational, 100% uptime
May 4: Operational, 100% uptime
May 5: Operational, 100% uptime
May 6: Operational, 100% uptime
May 7: Operational, 100% uptime
May 8: Outage, 94.6% uptime. Service unavailable
May 9: Operational, 100% uptime
May 10: Operational, 100% uptime
May 11: Operational, 100% uptime
May 12: Operational, 100% uptime
May 13: Operational, 100% uptime
May 14: Operational, 100% uptime
May 15: Operational, 100% uptime
May 16: Operational, 100% uptime
May 17: Operational, 100% uptime
May 18: Operational, 100% uptime
May 19: Operational, 100% uptime
May 20: Operational, 100% uptime
May 21: Operational, 100% uptime
May 22: Operational, 100% uptime
May 23: Operational, 100% uptime
May 24: Operational, 100% uptime
May 25: Operational, 100% uptime
May 26: Operational, 100% uptime
May 27: Operational, 100% uptime
May 28: Operational, 100% uptime
May 29: Operational, 100% uptime
May 30: Operational, 100% uptime
May 31: Degraded, 99.1% uptime. Elevated latency
Jun 1: Operational, 100% uptime
Jun 2: Operational, 100% uptime
Jun 3: Operational, 100% uptime
Jun 4: Operational, 100% uptime
Jun 5: Operational, 100% uptime
Jun 6: Operational, 100% uptime
Jun 7: Operational, 100% uptime
Jun 8: Operational, 100% uptime
Jun 9: Operational, 100% uptime
Jun 10: Operational, 100% uptime
Jun 11: Operational, 100% uptime
Jun 12: Operational, 100% uptime
Jun 13: Operational, 100% uptime
Jun 14: Operational, 100% uptime
Jun 15: Outage, 94.6% uptime. Service unavailable
Jun 16: Operational, 100% uptime
Jun 17: Operational, 100% uptime
Jun 18: Operational, 100% uptime
Jun 19: Operational, 100% uptime
Jun 20: Operational, 100% uptime
Jun 21: Operational, 100% uptime
Jun 22: Operational, 100% uptime
Jun 23: Operational, 100% uptime
Jun 24: Operational, 100% uptime
Jun 25: Operational, 100% uptime
Jun 26: Operational, 100% uptime
Jun 27: Operational, 100% uptime
Jun 28: Operational, 100% uptime
Jun 29: Operational, 100% uptime
Jun 30: Operational, 100% uptime
Jul 1: Operational, 100% uptime
Jul 2: Operational, 100% uptime
Jul 3: Degraded, 99.1% uptime. Elevated latency
Jul 4: Operational, 100% uptime
Jul 5: Operational, 100% uptime
Jul 6: Operational, 100% uptime
Jul 7: Operational, 100% uptime
Jul 8: Operational, 100% uptime
Jul 9: Operational, 100% uptime
Jul 10: Operational, 100% uptime
Jul 11: Operational, 100% uptime
Jul 12: Operational, 100% uptime
Jul 13: Operational, 100% uptime
Jul 14: Operational, 100% uptime
  • Operational 85
  • Degraded 3
  • Outage 2
API uptime, last 90 days
DayStatusUptimeNote
Apr 16Operational100%
Apr 17Operational100%
Apr 18Operational100%
Apr 19Operational100%
Apr 20Operational100%
Apr 21Operational100%
Apr 22Operational100%
Apr 23Operational100%
Apr 24Operational100%
Apr 25Operational100%
Apr 26Operational100%
Apr 27Operational100%
Apr 28Degraded99.1%Elevated latency
Apr 29Operational100%
Apr 30Operational100%
May 1Operational100%
May 2Operational100%
May 3Operational100%
May 4Operational100%
May 5Operational100%
May 6Operational100%
May 7Operational100%
May 8Outage94.6%Service unavailable
May 9Operational100%
May 10Operational100%
May 11Operational100%
May 12Operational100%
May 13Operational100%
May 14Operational100%
May 15Operational100%
May 16Operational100%
May 17Operational100%
May 18Operational100%
May 19Operational100%
May 20Operational100%
May 21Operational100%
May 22Operational100%
May 23Operational100%
May 24Operational100%
May 25Operational100%
May 26Operational100%
May 27Operational100%
May 28Operational100%
May 29Operational100%
May 30Operational100%
May 31Degraded99.1%Elevated latency
Jun 1Operational100%
Jun 2Operational100%
Jun 3Operational100%
Jun 4Operational100%
Jun 5Operational100%
Jun 6Operational100%
Jun 7Operational100%
Jun 8Operational100%
Jun 9Operational100%
Jun 10Operational100%
Jun 11Operational100%
Jun 12Operational100%
Jun 13Operational100%
Jun 14Operational100%
Jun 15Outage94.6%Service unavailable
Jun 16Operational100%
Jun 17Operational100%
Jun 18Operational100%
Jun 19Operational100%
Jun 20Operational100%
Jun 21Operational100%
Jun 22Operational100%
Jun 23Operational100%
Jun 24Operational100%
Jun 25Operational100%
Jun 26Operational100%
Jun 27Operational100%
Jun 28Operational100%
Jun 29Operational100%
Jun 30Operational100%
Jul 1Operational100%
Jul 2Operational100%
Jul 3Degraded99.1%Elevated latency
Jul 4Operational100%
Jul 5Operational100%
Jul 6Operational100%
Jul 7Operational100%
Jul 8Operational100%
Jul 9Operational100%
Jul 10Operational100%
Jul 11Operational100%
Jul 12Operational100%
Jul 13Operational100%
Jul 14Operational100%

Installation

Terminal
pnpm dlx @dowel-ui/cli add uptime-matrix

Installs dither-canvas as well, because this component imports it.

npm packages installed: class-variance-authority.

Accessibility

The tiles are role="img" with an aria-label counting each state and the average uptime. Over them sits a role="grid" of gridcells with one roving tab stop: arrow keys move a day, Home/End go to the row's ends, Ctrl+Home/End to the first and last day, Escape hides the readout; each cell's text is its readout. State is never colour alone: degraded tiles are striped, outages solid, and the legend, readout and table name every state. Axis labels are aria-hidden; the data is always in a table, visually hidden unless showTable is set.

Props

UptimeMatrix

PropTypeDefault
days (required)readonly UptimeDay[]
label (required)

What the matrix shows. Names the image, the grid and the data table.

string
activeIndex

The day whose readout shows (controlled); null hides it.

number | null
animatebooleantrue
cell

Cell pitch in CSS pixels. Default 2: tiles are thin.

number2
columns

Tiles per row. Default: every day in one row.

number
dayLabel

Data table headers.

string"Day"
defaultActiveIndexnumber | nullnull
description

Replaces the generated summary in the image's accessible name.

string
endLabelstring
formatUptime(percent: number) => string(percent) => `${String(Math.round(percent * 100) / 100)}%`
noteLabelstring"Note"
onActiveIndexChange(index: number | null) => void
showAxis

Show the first and last day under the tiles.

booleantrue
showLegend

Show the state legend with counts.

booleantrue
showTable

Show the data table. It is always present for assistive technology.

booleanfalse
size"sm" | "md" | "lg""md"
startLabel

Replace the axis labels, e.g. "90 days ago" and "Today".

string
statusLabelstring"Status"
statusLabels

Names for each state (translate here).

Partial<Record<UptimeStatus, string>>
uptimeLabelstring"Uptime"

Plus every attribute of <div> except children.

Quality

8/8 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testedpasses
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokensdoes not apply
  • className mergedpasses
  • Visible focusdoes not apply
  • No fixed widthspasses

Source

This is exactly what dowel add uptime-matrix writes into your project, with imports rewritten to your own path alias.

ui/uptime-matrix.tsx
"use client";

// Ported from amicro "System Uptime Matrix" (MIT, © 2026 Syed Subhan Uddin). See THIRD_PARTY_NOTICES.md.
import { cva, type VariantProps } from "class-variance-authority";
import {
  useRef,
  useState,
  type ComponentPropsWithRef,
  type FocusEvent,
  type KeyboardEvent,
} from "react";

import {
  DitherCanvas,
  ditherFill,
  DitherTable,
  drift,
  hash2,
  makePath,
  tokenToCss,
  traceRoundedRect,
  type DitherDraw,
} from "@/components/dither-canvas";
import { focusRing } from "@/lib/styles";
import { cn } from "@/lib/utils";

/*
 * A status history drawn in dithered tiles — amicro's 90-day uptime matrix.
 * Each day is a tall tile coloured by state: success, warning and destructive
 * are the right tokens here, because the colour *is* the state. Colour is not
 * the only cue: degraded tiles are striped, outages drawn solid, and every
 * state is named in the legend, the readout and the table.
 *
 * Over the canvas sits an APG grid with one roving tab stop. Arrows move a day
 * (Up/Down a row when the tiles wrap into `columns`), Home/End go to the row's
 * ends, Ctrl+Home/End to the first and last day, Escape hides the readout. Each
 * cell's text is its readout. Days run left to right in every direction, as
 * the canvas draws them.
 */

export const uptimeMatrixVariants = cva("flex flex-col gap-2", {
  variants: {
    size: {
      sm: "[--uptime-matrix-height:1.25rem]",
      md: "[--uptime-matrix-height:1.75rem]",
      lg: "[--uptime-matrix-height:2.5rem]",
    },
  },
  defaultVariants: { size: "md" },
});

export type UptimeStatus = "operational" | "degraded" | "outage" | "unknown";

export interface UptimeDay {
  /** The day's name, e.g. a date. */
  label: string;
  status: UptimeStatus;
  /** Uptime that day, in percent. */
  uptime?: number;
  /** A note for the readout, e.g. the incident. */
  note?: string;
}

export interface UptimeMatrixProps
  extends
    Omit<ComponentPropsWithRef<"div">, "children">,
    VariantProps<typeof uptimeMatrixVariants> {
  days: readonly UptimeDay[];
  /** What the matrix shows. Names the image, the grid and the data table. */
  label: string;
  /** Replaces the generated summary in the image's accessible name. */
  description?: string;
  /** Tiles per row. Default: every day in one row. */
  columns?: number;
  /** Names for each state (translate here). */
  statusLabels?: Partial<Record<UptimeStatus, string>>;
  formatUptime?: (percent: number) => string;
  /** Show the state legend with counts. */
  showLegend?: boolean;
  /** Show the first and last day under the tiles. */
  showAxis?: boolean;
  /** Replace the axis labels, e.g. "90 days ago" and "Today". */
  startLabel?: string;
  endLabel?: string;
  /** Show the data table. It is always present for assistive technology. */
  showTable?: boolean;
  animate?: boolean;
  /** Cell pitch in CSS pixels. Default 2: tiles are thin. */
  cell?: number;
  /** The day whose readout shows (controlled); null hides it. */
  activeIndex?: number | null;
  defaultActiveIndex?: number | null;
  onActiveIndexChange?: (index: number | null) => void;
  /** Data table headers. */
  dayLabel?: string;
  statusLabel?: string;
  uptimeLabel?: string;
  noteLabel?: string;
}

/** Gap between tiles in CSS pixels. */
export const UPTIME_GAP = 2;

export const UPTIME_STATUSES: readonly UptimeStatus[] = [
  "operational",
  "degraded",
  "outage",
  "unknown",
];

/** The state token for each status. */
export const UPTIME_COLORS: Readonly<Record<UptimeStatus, string>> = {
  operational: "success",
  degraded: "warning",
  outage: "destructive",
  unknown: "muted-foreground",
};

const DEFAULT_LABELS: Record<UptimeStatus, string> = {
  operational: "Operational",
  degraded: "Degraded",
  outage: "Outage",
  unknown: "No data",
};

/** The day a grid key moves to, or null when the key is not a grid key. */
export function dayFromKey(
  key: string,
  ctrl: boolean,
  index: number,
  count: number,
  columns: number,
): number | null {
  const last = count - 1;
  const clampTo = (value: number) => Math.min(last, Math.max(0, value));
  const rowStart = index - (index % columns);
  switch (key) {
    case "ArrowRight":
      return clampTo(index + 1);
    case "ArrowLeft":
      return clampTo(index - 1);
    case "ArrowDown":
      return index + columns <= last ? index + columns : index;
    case "ArrowUp":
      return index - columns >= 0 ? index - columns : index;
    case "Home":
      return ctrl ? 0 : rowStart;
    case "End":
      return ctrl ? last : clampTo(rowStart + columns - 1);
    default:
      return null;
  }
}

export function UptimeMatrix({
  days,
  label,
  description,
  columns,
  statusLabels,
  formatUptime = (percent) => `${String(Math.round(percent * 100) / 100)}%`,
  showLegend = true,
  showAxis = true,
  startLabel,
  endLabel,
  size,
  showTable = false,
  animate = true,
  cell = 2,
  activeIndex,
  defaultActiveIndex = null,
  onActiveIndexChange,
  dayLabel = "Day",
  statusLabel = "Status",
  uptimeLabel = "Uptime",
  noteLabel = "Note",
  className,
  ...props
}: UptimeMatrixProps) {
  const [uncontrolled, setUncontrolled] = useState<number | null>(defaultActiveIndex);
  const [focusIndex, setFocusIndex] = useState(0);
  const gridRef = useRef<HTMLDivElement>(null);
  const active = activeIndex !== undefined ? activeIndex : uncontrolled;
  const count = days.length;
  const perRow = Math.max(1, Math.min(count, columns ?? count));
  const rowCount = Math.ceil(count / perRow);
  const names = { ...DEFAULT_LABELS, ...statusLabels };

  const setActive = (next: number | null) => {
    if (next === active) return;
    if (activeIndex === undefined) setUncontrolled(next);
    onActiveIndexChange?.(next);
  };

  const draw: DitherDraw = (ctx, frame) => {
    const { width, height, time } = frame;
    if (count === 0) return;
    const tileWidth = (width - UPTIME_GAP * (perRow - 1)) / perRow;
    const tileHeight = (height - UPTIME_GAP * (rowCount - 1)) / rowCount;
    if (tileWidth <= 0 || tileHeight <= 0) return;
    const step = frame.cell;
    days.forEach((day, i) => {
      const x = (i % perRow) * (tileWidth + UPTIME_GAP);
      const y = Math.floor(i / perRow) * (tileHeight + UPTIME_GAP);
      const hot = active === i;
      const dim = active !== null && !hot;
      const status = day.status;
      ditherFill(ctx, {
        clip: makePath((sink) => {
          traceRoundedRect(sink, x, y, tileWidth, tileHeight, Math.min(2, tileWidth / 2));
        }),
        bounds: { x, y, width: tileWidth, height: tileHeight },
        cell: step,
        color: frame.color(UPTIME_COLORS[status]),
        alpha: (status === "unknown" ? 0.35 : 0.85) * (dim ? 0.55 : 1),
        density: (cx, cy) => {
          const base = (0.3 + 0.4 * drift(cx, cy, time * 1.2)) * (0.8 + 0.4 * hash2(cx, cy));
          if (status === "outage") return base + 0.3;
          // Degraded: diagonal stripes, so the state reads without its colour.
          if (status === "degraded" && Math.floor((cx + cy) / (step * 2)) % 2 === 0)
            return base * 0.35;
          return base + (hot ? 0.2 : 0);
        },
      });
    });
  };

  const describe = (i: number) => {
    const day = days[i];
    if (!day) return "";
    const uptime =
      day.uptime !== undefined
        ? `, ${formatUptime(day.uptime)} ${uptimeLabel.toLowerCase()}`
        : "";
    const note = day.note ? `. ${day.note}` : "";
    return `${day.label}: ${names[day.status]}${uptime}${note}`;
  };

  const counts = UPTIME_STATUSES.map(
    (status) => [status, days.filter((day) => day.status === status).length] as const,
  );
  const measured = days.filter((day) => day.uptime !== undefined);
  const average =
    measured.length > 0
      ? measured.reduce((sum, day) => sum + (day.uptime ?? 0), 0) / measured.length
      : null;
  const summary =
    description ??
    (count === 0
      ? "No data."
      : `${String(count)} days: ${counts
          .filter(([, n]) => n > 0)
          .map(([status, n]) => `${String(n)} ${names[status].toLowerCase()}`)
          .join(", ")}.` +
        (average !== null
          ? ` Average ${uptimeLabel.toLowerCase()} ${formatUptime(average)}.`
          : ""));

  const roving = Math.min(focusIndex, Math.max(0, count - 1));

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    if (event.key === "Escape") {
      if (active !== null) {
        event.preventDefault();
        setActive(null);
      }
      return;
    }
    const next = dayFromKey(event.key, event.ctrlKey || event.metaKey, roving, count, perRow);
    if (next === null) return;
    event.preventDefault();
    gridRef.current?.querySelector<HTMLElement>(`[data-index="${String(next)}"]`)?.focus();
  };

  const onBlur = (event: FocusEvent<HTMLDivElement>) => {
    if (!event.currentTarget.contains(event.relatedTarget)) setActive(null);
  };

  const gap = `${String(UPTIME_GAP)}px`;
  const rowsOf = Array.from({ length: rowCount }, (_, r) =>
    days.slice(r * perRow, (r + 1) * perRow).map((day, c) => ({ day, index: r * perRow + c })),
  );
  const activeX = active !== null ? ((active % perRow) + 0.5) / perRow : 0;
  const shift =
    active !== null && (active % perRow === 0 || activeX < 0.15)
      ? "0%"
      : active !== null && (active % perRow === perRow - 1 || activeX > 0.85)
        ? "-100%"
        : "-50%";
  const withUptime = measured.length > 0;
  const withNote = days.some((day) => day.note);

  return (
    <div
      data-slot="uptime-matrix"
      {...props}
      className={cn(uptimeMatrixVariants({ size }), className)}
    >
      <div className="flex flex-col gap-1.5" style={{ direction: "ltr" }}>
        <div
          data-slot="uptime-matrix-plot"
          className="relative"
          style={{
            height: `calc(var(--uptime-matrix-height) * ${String(Math.max(1, rowCount))} + ${String(
              UPTIME_GAP * Math.max(0, rowCount - 1),
            )}px)`,
          }}
        >
          <div role="img" aria-label={`${label}: ${summary}`} className="size-full">
            <DitherCanvas draw={draw} animate={animate} cell={cell} />
          </div>
          {count > 0 && (
            <div
              ref={gridRef}
              role="grid"
              // Focusable only by script, so the one roving cell stays the tab stop.
              tabIndex={-1}
              aria-label={label}
              className="absolute inset-0 flex flex-col"
              style={{ gap }}
              onKeyDown={onKeyDown}
              onBlur={onBlur}
              onPointerLeave={(event) => {
                if (!event.currentTarget.contains(document.activeElement)) setActive(null);
              }}
            >
              {rowsOf.map((row, r) => (
                <div
                  key={r}
                  role="row"
                  className="grid flex-1"
                  style={{
                    gap,
                    gridTemplateColumns: `repeat(${String(perRow)}, minmax(0, 1fr))`,
                  }}
                >
                  {row.map(({ day, index }) => (
                    <div
                      key={`${day.label}-${String(index)}`}
                      role="gridcell"
                      data-index={index}
                      data-status={day.status}
                      data-active={active === index || undefined}
                      tabIndex={roving === index ? 0 : -1}
                      className={cn("rounded-xs", focusRing)}
                      onPointerEnter={() => {
                        setActive(index);
                      }}
                      onFocus={() => {
                        setFocusIndex(index);
                        setActive(index);
                      }}
                    >
                      <span className="sr-only">{describe(index)}</span>
                    </div>
                  ))}
                </div>
              ))}
            </div>
          )}
          {active !== null && days[active] && (
            <div
              aria-hidden
              data-slot="uptime-matrix-readout"
              className="pointer-events-none absolute z-10 rounded-md border border-border bg-popover px-2.5 py-1.5 text-xs whitespace-nowrap text-popover-foreground shadow-md"
              style={{
                left: `${String(activeX * 100)}%`,
                top: `${String((Math.floor(active / perRow) / rowCount) * 100)}%`,
                transform: `translate(${shift}, calc(-100% - 0.375rem))`,
              }}
            >
              <p className="font-medium">{days[active].label}</p>
              <p className="flex items-center gap-1.5 text-muted-foreground">
                <span
                  className="size-2 rounded-full"
                  style={{ background: tokenToCss(UPTIME_COLORS[days[active].status]) }}
                />
                {names[days[active].status]}
                {days[active].uptime !== undefined && (
                  <span className="tabular-nums">· {formatUptime(days[active].uptime)}</span>
                )}
              </p>
              {days[active].note && (
                <p className="text-muted-foreground">{days[active].note}</p>
              )}
            </div>
          )}
        </div>
        {showAxis && count > 0 && (
          <div
            aria-hidden
            data-slot="uptime-matrix-axis"
            className="flex justify-between gap-3 font-mono text-2xs text-muted-foreground"
          >
            <span>{startLabel ?? days[0]?.label}</span>
            {average !== null && (
              <span className="tabular-nums">
                {formatUptime(average)} {uptimeLabel.toLowerCase()}
              </span>
            )}
            <span>{endLabel ?? days[count - 1]?.label}</span>
          </div>
        )}
      </div>
      {showLegend && (
        <ul
          data-slot="uptime-matrix-legend"
          aria-label={`${label} legend`}
          className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground"
        >
          {counts
            .filter(([status, n]) => status !== "unknown" || n > 0)
            .map(([status, n]) => (
              <li key={status} className="flex items-center gap-1.5">
                <span
                  aria-hidden
                  className="size-2.5 rounded-xs"
                  style={{ background: tokenToCss(UPTIME_COLORS[status]) }}
                />
                {names[status]} <span className="tabular-nums">{n}</span>
              </li>
            ))}
        </ul>
      )}
      <DitherTable
        caption={label}
        columns={[
          dayLabel,
          statusLabel,
          ...(withUptime ? [uptimeLabel] : []),
          ...(withNote ? [noteLabel] : []),
        ]}
        rows={days.map((day) => [
          day.label,
          names[day.status],
          ...(withUptime ? [day.uptime !== undefined ? formatUptime(day.uptime) : ""] : []),
          ...(withNote ? [day.note ?? ""] : []),
        ])}
        visible={showTable}
      />
    </div>
  );
}