EdTech

Gradebook

A students-by-assignments gradebook table with color-coded grade cells, a per-student average column, and a sticky name column for horizontal scroll.

Install

npx shadcn@latest add @paragon/gradebook

gradebook.tsx

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