A CI build matrix grid of OS by runtime version, with pass, fail, running, and skipped cells and per-cell durations.
npx shadcn@latest add @paragon/build-matrix"use client";
import * as React from "react";
import { Check, Loader2, Minus, X } from "lucide-react";
import { cn } from "@/lib/utils";
export type CellStatus = "pass" | "fail" | "running" | "skipped";
export interface BuildMatrixProps extends React.ComponentProps<"div"> {
/** Row headers, e.g. OS. */
rows?: string[];
/** Column headers, e.g. runtime versions. */
columns?: string[];
/** status[row][col]. */
cells?: CellStatus[][];
/** Duration label per cell (optional), same shape as cells. */
durations?: (string | undefined)[][];
}
const DEFAULT_ROWS = ["ubuntu-24.04", "macos-14", "windows-2022"];
const DEFAULT_COLS = ["18", "20", "22"];
const DEFAULT_CELLS: CellStatus[][] = [
["pass", "pass", "pass"],
["pass", "pass", "running"],
["pass", "fail", "skipped"],
];
const DEFAULT_DURATIONS: (string | undefined)[][] = [
["1m 12s", "1m 08s", "1m 20s"],
["2m 04s", "1m 58s", undefined],
["2m 41s", "2m 55s", undefined],
];
const CELL: Record<
CellStatus,
{ color: string; label: string }
> = {
pass: { color: "var(--color-success)", label: "Passed" },
fail: { color: "var(--color-destructive)", label: "Failed" },
running: { color: "var(--color-warning)", label: "Running" },
skipped: { color: "var(--color-muted-foreground)", label: "Skipped" },
};
function CellIcon({ status }: { status: CellStatus }) {
const cls = "size-4";
const color = CELL[status].color;
if (status === "pass") return <Check className={cls} style={{ color }} />;
if (status === "fail") return <X className={cls} style={{ color }} />;
if (status === "running")
return (
<Loader2
className={cn(cls, "animate-spin motion-reduce:animate-none")}
style={{ color }}
/>
);
return <Minus className={cls} style={{ color }} />;
}
export function BuildMatrix({
rows = DEFAULT_ROWS,
columns = DEFAULT_COLS,
cells = DEFAULT_CELLS,
durations = DEFAULT_DURATIONS,
className,
...props
}: BuildMatrixProps) {
const gridCols = `minmax(120px,auto) repeat(${columns.length}, minmax(72px,1fr))`;
return (
<div
className={cn(
"w-full max-w-lg overflow-x-auto rounded-xl bg-card p-4 text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium">CI build matrix</span>
<span className="text-[11px] text-muted-foreground">node version</span>
</div>
<div style={{ minWidth: 360 }}>
<div
className="grid items-center gap-1.5"
style={{ gridTemplateColumns: gridCols }}
>
<span />
{columns.map((c) => (
<span
key={c}
className="text-center font-mono text-xs font-medium text-muted-foreground"
>
{c}
</span>
))}
{rows.map((r, ri) => (
<React.Fragment key={r}>
<span className="truncate pr-2 font-mono text-xs font-medium">
{r}
</span>
{columns.map((c, ci) => {
const status = cells[ri]?.[ci] ?? "skipped";
const dur = durations?.[ri]?.[ci];
const meta = CELL[status];
return (
<div
key={c}
title={`${r} · node ${c} — ${meta.label}${dur ? ` (${dur})` : ""}`}
className="flex aspect-[3/2] flex-col items-center justify-center gap-0.5 rounded-md"
style={{
background: `color-mix(in oklch, ${meta.color} 10%, var(--color-secondary))`,
boxShadow: `inset 0 0 0 1px color-mix(in oklch, ${meta.color} 30%, transparent)`,
}}
>
<CellIcon status={status} />
{dur && (
<span className="text-[9px] tabular-nums text-muted-foreground">
{dur}
</span>
)}
</div>
);
})}
</React.Fragment>
))}
</div>
</div>
</div>
);
}