A model-by-task score heatmap that tints from destructive to success by score, pops in on view, and highlights the row and column on cell hover or focus.
npx shadcn@latest add @paragon/eval-score-grid"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface EvalScoreGridProps extends React.ComponentProps<"div"> {
/** Row labels — models. */
models: string[];
/** Column labels — tasks. */
tasks: string[];
/** scores[modelIndex][taskIndex], each 0–1. Null renders as "no run". */
scores: (number | null)[][];
/** Formats a cell score for the tooltip and drill-in. Defaults to a %. */
formatScore?: (score: number) => string;
static?: boolean;
}
interface HoverCell {
m: number;
t: number;
}
// Score -> tint. Low scores lean destructive, high lean success, via a mix.
function cellStyle(score: number | null): React.CSSProperties {
if (score == null) return { background: "var(--color-secondary)" };
// Blend from destructive (0) through warning (0.5) to success (1).
const stops =
score < 0.5
? `color-mix(in oklab, var(--color-warning) ${score * 200}%, var(--color-destructive))`
: `color-mix(in oklab, var(--color-success) ${(score - 0.5) * 200}%, var(--color-warning))`;
return {
background: `color-mix(in oklab, ${stops} 78%, var(--color-card))`,
};
}
/**
* A model-by-task score heatmap. Cells tint from destructive through warning to
* success by score and pop in on first view with a staggered scale-and-fade
* (transform/opacity only). Hovering or focusing a cell raises a tooltip and
* highlights its row and column header (drill-in). Cells are buttons — full
* keyboard traversal, aria-labelled with model, task, and score. Reduced
* motion renders the grid filled with no pop.
*/
export function EvalScoreGrid({
models,
tasks,
scores,
formatScore = (s) => `${(s * 100).toFixed(0)}%`,
static: isStatic = false,
className,
...props
}: EvalScoreGridProps) {
const ref = React.useRef<HTMLDivElement>(null);
const reducedMotion = useReducedMotion();
const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });
const animate = !isStatic && !reducedMotion;
const shown = !animate || inView;
const [hover, setHover] = React.useState<HoverCell | null>(null);
return (
<div
ref={ref}
data-slot="eval-score-grid"
className={cn(
"w-full max-w-xl overflow-x-auto rounded-xl bg-card p-4 shadow-border",
className,
)}
{...props}
>
<div
className="grid gap-1"
style={{
gridTemplateColumns: `minmax(88px, auto) repeat(${tasks.length}, minmax(52px, 1fr))`,
}}
>
{/* Header row */}
<div />
{tasks.map((task, t) => (
<div
key={task}
className={cn(
"truncate px-1 pb-1 text-center text-[11px] font-medium transition-colors duration-(--duration-fast)",
hover?.t === t ? "text-foreground" : "text-muted-foreground",
)}
title={task}
>
{task}
</div>
))}
{models.map((model, m) => (
<React.Fragment key={model}>
<div
className={cn(
"flex items-center truncate pr-2 text-xs font-medium transition-colors duration-(--duration-fast)",
hover?.m === m ? "text-foreground" : "text-muted-foreground",
)}
title={model}
>
{model}
</div>
{tasks.map((task, t) => {
const score = scores[m]?.[t] ?? null;
const i = m * tasks.length + t;
const isFocusRowCol = hover?.m === m || hover?.t === t;
const isExact = hover?.m === m && hover?.t === t;
return (
<button
key={task}
type="button"
aria-label={`${model}, ${task}: ${
score == null ? "no run" : formatScore(score)
}`}
onMouseEnter={() => setHover({ m, t })}
onMouseLeave={() => setHover(null)}
onFocus={() => setHover({ m, t })}
onBlur={() => setHover(null)}
className={cn(
"relative grid aspect-[1.6/1] place-items-center rounded-md text-[11px] font-medium text-foreground/90 outline-none",
"transition-[opacity,transform,box-shadow] duration-(--duration-fast) ease-(--ease-out)",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-card",
hover && !isFocusRowCol && "opacity-45",
isExact && "shadow-border-hover",
)}
style={{
...cellStyle(score),
opacity: shown ? undefined : 0,
transform: shown
? isExact
? "scale(1.06)"
: "scale(1)"
: "scale(0.9)",
transitionDelay:
animate && !shown ? `${i * 18}ms` : undefined,
}}
>
<span className="tabular-nums">
{score == null ? "–" : formatScore(score)}
</span>
</button>
);
})}
</React.Fragment>
))}
</div>
<div
aria-live="polite"
className="mt-3 flex h-4 items-center gap-2 text-xs"
>
{hover ? (
<>
<span className="font-medium text-foreground">
{models[hover.m]}
</span>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">{tasks[hover.t]}</span>
<span className="ml-auto font-semibold tabular-nums text-foreground">
{scores[hover.m]?.[hover.t] == null
? "No run"
: formatScore(scores[hover.m]![hover.t]!)}
</span>
</>
) : (
<span className="text-muted-foreground">
Hover a cell to inspect a score
</span>
)}
</div>
</div>
);
}