A students-by-assignments gradebook table with color-coded grade cells, a per-student average column, and a sticky name column for horizontal scroll.
npx shadcn@latest add @paragon/gradebook"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface GradebookStudent {
name: string;
/** One score per assignment column, 0–100. null = missing. */
scores: (number | null)[];
}
export interface GradebookProps extends React.ComponentProps<"div"> {
/** Short column headers for assignments. */
assignments?: string[];
students?: GradebookStudent[];
}
const DEFAULT_ASSIGNMENTS = ["HW1", "HW2", "Quiz", "Mid", "HW3", "Final"];
const DEFAULT_STUDENTS: GradebookStudent[] = [
{ name: "Ava Thompson", scores: [95, 88, 92, 90, 97, 94] },
{ name: "Noah Patel", scores: [72, 68, 80, 74, null, 71] },
{ name: "Mia Chen", scores: [88, 91, 85, 89, 93, 90] },
{ name: "Liam García", scores: [64, 59, 70, 61, 66, 58] },
{ name: "Sofia Rossi", scores: [100, 96, 98, 95, 99, 97] },
];
function grade(v: number | null) {
if (v == null)
return { cell: "bg-secondary text-muted-foreground", text: "—" };
if (v >= 90) return { cell: "bg-success/15 text-success", text: String(v) };
if (v >= 80) return { cell: "bg-success/10 text-foreground", text: String(v) };
if (v >= 70) return { cell: "bg-warning/15 text-warning", text: String(v) };
return { cell: "bg-destructive/15 text-destructive", text: String(v) };
}
/**
* A gradebook table of students × assignments with color-coded cells (green
* band for A/B, amber for C, red below), a per-student average column, and a
* sticky first column so names stay visible while the grid scrolls horizontally.
* Pure CSS coloring — no animation — and all figures are tabular.
*/
export function Gradebook({
assignments = DEFAULT_ASSIGNMENTS,
students = DEFAULT_STUDENTS,
className,
...props
}: GradebookProps) {
const avg = (scores: (number | null)[]) => {
const present = scores.filter((s): s is number => s != null);
if (!present.length) return null;
return Math.round(present.reduce((a, b) => a + b, 0) / present.length);
};
return (
<div
data-slot="gradebook"
className={cn(
"w-full max-w-lg overflow-hidden rounded-xl bg-card text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="border-b border-border px-4 py-3">
<h3 className="text-sm font-medium">Gradebook</h3>
<p className="text-xs text-muted-foreground">
CS 202 · Section B · {students.length} students
</p>
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="text-xs text-muted-foreground">
<th className="sticky left-0 z-10 bg-card px-4 py-2 text-left font-medium">
Student
</th>
{assignments.map((a) => (
<th
key={a}
className="px-2 py-2 text-center font-medium tabular-nums"
>
{a}
</th>
))}
<th className="px-3 py-2 text-center font-medium">Avg</th>
</tr>
</thead>
<tbody>
{students.map((s) => {
const a = avg(s.scores);
const ag = grade(a);
return (
<tr key={s.name} className="border-t border-border">
<th
scope="row"
className="sticky left-0 z-10 whitespace-nowrap bg-card px-4 py-2 text-left font-medium"
>
{s.name}
</th>
{s.scores.map((v, i) => {
const g = grade(v);
return (
<td key={i} className="px-1.5 py-1.5 text-center">
<span
className={cn(
"inline-flex h-7 w-9 items-center justify-center rounded-md text-xs font-medium tabular-nums",
g.cell,
)}
>
{g.text}
</span>
</td>
);
})}
<td className="px-2 py-1.5 text-center">
<span
className={cn(
"inline-flex h-7 w-9 items-center justify-center rounded-md text-xs font-semibold tabular-nums",
ag.cell,
)}
>
{ag.text}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}