AI & Chat

Eval Score Grid

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.

Install

npx shadcn@latest add @paragon/eval-score-grid

eval-score-grid.tsx

"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>
  );
}