# Datatable 19 - Tree Table

> A tree table with nested programs, courses, and classes, status and completion filters, and totals rolled up to every parent. Useful for school dashboards, org charts, and nested data.

A shadcn/ui block from the [@shadcn-dashboard](https://shadcndashboard.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/shadcn-dashboard/datatable-19).

- Page, with a live preview: https://shadcn.exchange/r/shadcn-dashboard/datatable-19
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/shadcn-dashboard/datatable-19-light-95bdcc9fbdd50bc2.png
- Registry item: https://shadcndashboard.dev/r/datatable-19.json
- Listed: 2026-10-20, source last changed 2026-10-20

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/shadcn-dashboard/datatable-19.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `badge`, `button`, `card`, `dropdown-menu`, `table`

## Files

### `src/components/shadcn-dashboard/blocks/datatable/datatable-19/page.tsx`

```tsx
import TreeTable from "@/components/shadcn-dashboard/blocks/datatable/datatable-19/datatable"

const page = () => {
  return <TreeTable />
}

export default page
```

### `src/components/shadcn-dashboard/blocks/datatable/datatable-19/datatable.tsx`

```tsx
"use client";

import { useEffect, useMemo, useState } from "react";
import {
  BookOpenIcon,
  ChevronRightIcon,
  ChevronsDownUpIcon,
  ChevronsUpDownIcon,
  GraduationCapIcon,
  ListFilterIcon,
  PresentationIcon,
  UsersIcon,
  XIcon,
  type LucideIcon,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardAction,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuLabel,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";

type ClassStatus = "Active" | "Upcoming" | "Completed" | "On Hold";

type Node = {
  id: string;
  name: string;
  /** leaf only */
  teacher?: string;
  students?: number;
  completion?: number;
  status?: ClassStatus;
  schedule?: string;
  children?: Node[];
};

type Row = {
  node: Node;
  level: number;
  students: number;
  completion: number;
  classCount: number;
  doneCount: number;
};

const curriculum: Node[] = [
  {
    id: "science",
    name: "Science & Technology",
    children: [
      {
        id: "cs",
        name: "Computer Science 101",
        children: [
          { id: "cs-1", name: "Intro to Algorithms", teacher: "Mr. D. Weber", students: 42, completion: 82, status: "Active", schedule: "Mon, Wed \u00b7 09:00" },
          { id: "cs-2", name: "Data Structures Lab", teacher: "Ms. R. Iyer", students: 38, completion: 64, status: "Active", schedule: "Tue, Thu \u00b7 11:00" },
        ],
      },
      {
        id: "physics",
        name: "Applied Physics",
        children: [
          { id: "ph-1", name: "Mechanics", teacher: "Dr. L. Moreau", students: 35, completion: 91, status: "Completed", schedule: "Mon, Fri \u00b7 10:30" },
          { id: "ph-2", name: "Optics Workshop", teacher: "Dr. K. Mensah", students: 28, completion: 22, status: "Upcoming", schedule: "Wed \u00b7 14:00" },
        ],
      },
    ],
  },
  {
    id: "humanities",
    name: "Humanities",
    children: [
      {
        id: "history",
        name: "World History",
        children: [
          { id: "hi-1", name: "Ancient Civilizations", teacher: "Ms. P. Santos", students: 46, completion: 77, status: "Active", schedule: "Tue, Thu \u00b7 09:30" },
          { id: "hi-2", name: "Modern Europe", teacher: "Mr. T. Larsen", students: 40, completion: 35, status: "On Hold", schedule: "Mon \u00b7 13:00" },
        ],
      },
      {
        id: "writing",
        name: "Creative Writing",
        children: [
          { id: "wr-1", name: "Short Fiction", teacher: "Ms. A. Okoye", students: 24, completion: 88, status: "Active", schedule: "Wed, Fri \u00b7 11:30" },
          { id: "wr-2", name: "Poetry Circle", teacher: "Mr. J. Brandt", students: 19, completion: 12, status: "Upcoming", schedule: "Thu \u00b7 15:00" },
        ],
      },
    ],
  },
  {
    id: "arts",
    name: "Arts & Design",
    children: [
      {
        id: "visual",
        name: "Visual Design",
        children: [
          { id: "vd-1", name: "Typography", teacher: "Ms. C. Duarte", students: 30, completion: 71, status: "Active", schedule: "Mon, Wed \u00b7 12:00" },
          { id: "vd-2", name: "Color Theory", teacher: "Mr. H. Kim", students: 33, completion: 95, status: "Completed", schedule: "Tue \u00b7 10:00" },
        ],
      },
      {
        id: "media",
        name: "Digital Media",
        children: [
          { id: "dm-1", name: "Video Editing", teacher: "Ms. S. Nair", students: 27, completion: 58, status: "Active", schedule: "Thu, Fri \u00b7 09:00" },
          { id: "dm-2", name: "Motion Graphics", teacher: "Mr. E. Vogel", students: 21, completion: 18, status: "Upcoming", schedule: "Fri \u00b7 13:30" },
        ],
      },
    ],
  },
];

const STATUS_STYLES: Record<ClassStatus, string> = {
  Active: "bg-chart-2/10! text-chart-2!",
  Upcoming: "bg-sky-400/10! text-sky-400!",
  Completed: "bg-teal-400/10! text-teal-400!",
  "On Hold": "bg-orange-400/10! text-orange-400!",
};

const SUMMARY_STYLE = "bg-primary/10! text-primary!";

const LEVELS: { icon: LucideIcon; color: string }[] = [
  { icon: GraduationCapIcon, color: "text-primary" },
  { icon: BookOpenIcon, color: "text-blue-500" },
  { icon: PresentationIcon, color: "text-muted-foreground" },
];

const completionStroke = (value: number) =>
  value >= 75
    ? "stroke-chart-2"
    : value >= 40
      ? "stroke-orange-400"
      : "stroke-destructive";

type Totals = { students: number; weighted: number; classes: number; done: number };

/** totals of a node, rolled up from its leaf classes */
function rollUp(node: Node): Totals {
  if (!node.children) {
    const students = node.students ?? 0;
    return {
      students,
      weighted: students * (node.completion ?? 0),
      classes: 1,
      done: node.status === "Completed" ? 1 : 0,
    };
  }
  return node.children.map(rollUp).reduce(
    (acc, c) => ({
      students: acc.students + c.students,
      weighted: acc.weighted + c.weighted,
      classes: acc.classes + c.classes,
      done: acc.done + c.done,
    }),
    { students: 0, weighted: 0, classes: 0, done: 0 },
  );
}

function collectParentIds(nodes: Node[]): string[] {
  return nodes.flatMap((n) =>
    n.children ? [n.id, ...collectParentIds(n.children)] : [],
  );
}

function flatten(nodes: Node[], expanded: Set<string>, level = 0): Row[] {
  return nodes.flatMap((node) => {
    const total = rollUp(node);
    const row: Row = {
      node,
      level,
      students: total.students,
      completion: total.students ? Math.round(total.weighted / total.students) : 0,
      classCount: total.classes,
      doneCount: total.done,
    };
    return node.children && expanded.has(node.id)
      ? [row, ...flatten(node.children, expanded, level + 1)]
      : [row];
  });
}

type CompletionFilter = "all" | "low" | "mid" | "high";

const STATUS_DOTS: Record<ClassStatus, string> = {
  Active: "bg-chart-2",
  Upcoming: "bg-sky-400",
  Completed: "bg-teal-400",
  "On Hold": "bg-orange-400",
};

const COMPLETION_OPTIONS: { value: CompletionFilter; label: string; dot?: string }[] = [
  { value: "all", label: "All completion" },
  { value: "low", label: "Below 40%", dot: "bg-destructive" },
  { value: "mid", label: "40% to 74%", dot: "bg-orange-400" },
  { value: "high", label: "75% and above", dot: "bg-chart-2" },
];

const STATUS_OPTIONS: ClassStatus[] = ["Active", "Upcoming", "Completed", "On Hold"];

function inRange(value: number, range: CompletionFilter) {
  if (range === "low") return value < 40;
  if (range === "mid") return value >= 40 && value < 75;
  if (range === "high") return value >= 75;
  return true;
}

function collectLeaves(nodes: Node[]): Node[] {
  return nodes.flatMap((n) => (n.children ? collectLeaves(n.children) : [n]));
}

/** keeps matching classes and the courses and programs that contain them */
function filterTree(nodes: Node[], keep: (leaf: Node) => boolean): Node[] {
  return nodes.flatMap((n) => {
    if (!n.children) return keep(n) ? [n] : [];
    const children = filterTree(n.children, keep);
    return children.length > 0 ? [{ ...n, children }] : [];
  });
}

function countLevels(nodes: Node[], level = 0, acc: number[] = [0, 0, 0]): number[] {
  for (const n of nodes) {
    acc[level] += 1;
    if (n.children) countLevels(n.children, level + 1, acc);
  }
  return acc;
}

function TreeTable() {
  const [statusFilter, setStatusFilter] = useState<ClassStatus | "all">("all");
  const [completionFilter, setCompletionFilter] = useState<CompletionFilter>("all");
  const [expanded, setExpanded] = useState<Set<string>>(
    new Set(curriculum.map((n) => n.id)),
  );
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    const t = setTimeout(() => setMounted(true), 150);
    return () => clearTimeout(t);
  }, []);

  const isFiltered = statusFilter !== "all" || completionFilter !== "all";

  const tree = useMemo(
    () =>
      filterTree(
        curriculum,
        (leaf) =>
          (statusFilter === "all" || leaf.status === statusFilter) &&
          inRange(leaf.completion ?? 0, completionFilter),
      ),
    [statusFilter, completionFilter],
  );

  const allLeaves = useMemo(() => collectLeaves(curriculum), []);
  const visibleParents = useMemo(() => collectParentIds(tree), [tree]);
  const [programs, courses, classes] = useMemo(() => countLevels(tree), [tree]);
  const rows = useMemo(() => flatten(tree, expanded), [tree, expanded]);
  const allOpen =
    visibleParents.length > 0 && visibleParents.every((id) => expanded.has(id));

  // counts next to each option follow the other active filter
  const statusCount = (st: ClassStatus) =>
    allLeaves.filter(
      (l) => l.status === st && inRange(l.completion ?? 0, completionFilter),
    ).length;
  const completionCount = (range: CompletionFilter) =>
    allLeaves.filter(
      (l) =>
        (statusFilter === "all" || l.status === statusFilter) &&
        inRange(l.completion ?? 0, range),
    ).length;

  const openEverything = () => setExpanded(new Set(collectParentIds(curriculum)));

  const changeStatus = (value: ClassStatus | "all") => {
    setStatusFilter(value);
    openEverything();
  };
  const changeCompletion = (value: CompletionFilter) => {
    setCompletionFilter(value);
    openEverything();
  };
  const clearFilters = () => {
    setStatusFilter("all");
    setCompletionFilter("all");
    setExpanded(new Set(curriculum.map((n) => n.id)));
  };

  const toggle = (id: string) =>
    setExpanded((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });

  return (
    <section className="py-8 px-4 max-w-4xl w-full mx-auto">
      <Card className="w-full py-0 gap-0 ring-0 border border-border">
        <CardHeader className="flex items-center justify-between flex-wrap gap-4 p-3.5">
          <div>
            <CardTitle>Course Curriculum</CardTitle>
            <CardDescription>
              {programs} programs &middot; {courses} courses &middot; {classes} classes
            </CardDescription>
          </div>
          <CardAction className="flex flex-wrap items-center gap-2">
            <DropdownMenu>
              <DropdownMenuTrigger
                render={
                  <Button variant="outline" className="cursor-pointer">
                    <ListFilterIcon className="size-4" />
                    {statusFilter === "all" ? "Status" : statusFilter}
                    {statusFilter !== "all" && (
                      <span className={cn("size-2 rounded-full", STATUS_DOTS[statusFilter])} />
                    )}
                  </Button>
                }
              />
              <DropdownMenuContent align="end" className="w-52">
                <DropdownMenuGroup>
                  <DropdownMenuLabel>Filter by status</DropdownMenuLabel>
                </DropdownMenuGroup>
                <DropdownMenuSeparator />
                <DropdownMenuRadioGroup
                  value={statusFilter}
                  onValueChange={(v) => changeStatus(v as ClassStatus | "all")}
                >
                  <DropdownMenuRadioItem value="all" className="cursor-pointer">
                    <span className="flex-1">All statuses</span>
                    <span className="text-xs text-muted-foreground">
                      {allLeaves.filter((l) => inRange(l.completion ?? 0, completionFilter)).length}
                    </span>
                  </DropdownMenuRadioItem>
                  {STATUS_OPTIONS.map((st) => (
                    <DropdownMenuRadioItem key={st} value={st} className="cursor-pointer">
                      <span className={cn("size-2 rounded-full", STATUS_DOTS[st])} />
                      <span className="flex-1">{st}</span>
                      <span className="text-xs text-muted-foreground">{statusCount(st)}</span>
                    </DropdownMenuRadioItem>
                  ))}
                </DropdownMenuRadioGroup>
              </DropdownMenuContent>
            </DropdownMenu>

            <DropdownMenu>
              <DropdownMenuTrigger
                render={
                  <Button variant="outline" className="cursor-pointer">
                    <ListFilterIcon className="size-4" />
                    {completionFilter === "all"
                      ? "Completion"
                      : COMPLETION_OPTIONS.find((o) => o.value === completionFilter)?.label}
                  </Button>
                }
              />
              <DropdownMenuContent align="end" className="w-52">
                <DropdownMenuGroup>
                  <DropdownMenuLabel>Filter by completion</DropdownMenuLabel>
                </DropdownMenuGroup>
                <DropdownMenuSeparator />
                <DropdownMenuRadioGroup
                  value={completionFilter}
                  onValueChange={(v) => changeCompletion(v as CompletionFilter)}
                >
                  {COMPLETION_OPTIONS.map((o) => (
                    <DropdownMenuRadioItem key={o.value} value={o.value} className="cursor-pointer">
                      {o.dot && <span className={cn("size-2 rounded-full", o.dot)} />}
                      <span className="flex-1">{o.label}</span>
                      <span className="text-xs text-muted-foreground">
                        {completionCount(o.value)}
                      </span>
                    </DropdownMenuRadioItem>
                  ))}
                </DropdownMenuRadioGroup>
              </DropdownMenuContent>
            </DropdownMenu>

            <Button
              variant="outline"
              className="cursor-pointer"
              disabled={visibleParents.length === 0}
              onClick={() =>
                setExpanded(allOpen ? new Set() : new Set(collectParentIds(curriculum)))
              }
            >
              {allOpen ? (
                <ChevronsDownUpIcon className="size-4" />
              ) : (
                <ChevronsUpDownIcon className="size-4" />
              )}
              {allOpen ? "Collapse all" : "Expand all"}
            </Button>
          </CardAction>
        </CardHeader>

        {isFiltered && (
          <div className="flex flex-wrap items-center gap-2 border-t border-border p-3.5">
            {statusFilter !== "all" && (
              <Badge variant="outline" className="gap-1.5">
                <span className="text-muted-foreground">Status:</span>
                {statusFilter}
                <button
                  type="button"
                  className="cursor-pointer"
                  aria-label="Remove status filter"
                  onClick={() => changeStatus("all")}
                >
                  <XIcon className="size-3" />
                </button>
              </Badge>
            )}
            {completionFilter !== "all" && (
              <Badge variant="outline" className="gap-1.5">
                <span className="text-muted-foreground">Completion:</span>
                {COMPLETION_OPTIONS.find((o) => o.value === completionFilter)?.label}
                <button
                  type="button"
                  className="cursor-pointer"
                  aria-label="Remove completion filter"
                  onClick={() => changeCompletion("all")}
                >
                  <XIcon className="size-3" />
                </button>
              </Badge>
            )}
            <Button variant="ghost" className="cursor-pointer" onClick={clearFilters}>
              Clear all
            </Button>
          </div>
        )}

        <div className="border-t border-border">
          <Table className="min-w-full">
            <TableHeader>
              <TableRow>
                <TableHead className="text-base font-medium py-3 text-left border-b border-border px-4 h-auto">
                  Name
                </TableHead>
                <TableHead className="text-base font-medium py-3 text-left border-b border-border px-4 h-auto">
                  Teacher
                </TableHead>
                <TableHead className="text-base font-medium py-3 text-right border-b border-border px-4 h-auto">
                  Students
                </TableHead>
                <TableHead className="text-base font-medium py-3 text-left border-b border-border px-4 h-auto">
                  Completion
                </TableHead>
                <TableHead className="text-base font-medium py-3 text-left border-b border-border px-4 h-auto">
                  Status
                </TableHead>
              </TableRow>
            </TableHeader>
            <TableBody className="divide-y divide-border">
              {rows.length === 0 && (
                <TableRow>
                  <TableCell
                    colSpan={5}
                    className="h-24 text-center text-muted-foreground"
                  >
                    No classes match these filters.
                  </TableCell>
                </TableRow>
              )}
              {rows.map(({ node, level, students, completion, classCount, doneCount }) => {
                const isParent = !!node.children;
                const isOpen = expanded.has(node.id);
                const { icon: Icon, color } = LEVELS[level] ?? LEVELS[2];
                return (
                  <TableRow
                    key={node.id}
                    className={cn(
                      "hover:bg-muted/50",
                      level === 0 && "bg-muted/30",
                      level > 0 && "animate-in fade-in-0 duration-300",
                    )}
                  >
                    <TableCell className="whitespace-nowrap py-3 px-4">
                      <div
                        className="flex items-center gap-2"
                        style={{ paddingLeft: level * 24 }}
                      >
                        <div className="flex items-center gap-2">
                          {isParent ? (
                            <Button
                              variant="ghost"
                              size="icon-sm"
                              className="cursor-pointer"
                              aria-expanded={isOpen}
                              aria-label={`${isOpen ? "Collapse" : "Expand"} ${node.name}`}
                              onClick={() => toggle(node.id)}
                            >
                              <ChevronRightIcon
                                className={cn(
                                  "size-4 transition-transform duration-300 ease-out",
                                  isOpen && "rotate-90",
                                )}
                              />
                            </Button>
                          ) : (
                            <span className="w-7 shrink-0" />
                          )}
                          <Icon className={cn("size-4 shrink-0", color)} />
                          <div className="flex flex-col">
                            <h6 className="text-sm font-medium">{node.name}</h6>
                            <span className="text-xs text-muted-foreground">
                              {isParent
                                ? `${classCount} ${classCount === 1 ? "class" : "classes"}`
                                : node.schedule}
                            </span>
                          </div>
                        </div>
                      </div>
                    </TableCell>
                    <TableCell className="whitespace-nowrap py-3 px-4">
                      {node.teacher ?? <span className="text-muted-foreground">&mdash;</span>}
                    </TableCell>
                    <TableCell className="whitespace-nowrap py-3 px-4">
                      <div className="flex items-center justify-end gap-1.5">
                        <UsersIcon className="size-4 text-muted-foreground" />
                        {students}
                      </div>
                    </TableCell>
                    <TableCell className="whitespace-nowrap py-3 px-4">
                      <div className="flex items-center gap-2">
                        <svg viewBox="0 0 36 36" className="size-7 -rotate-90">
                          <circle
                            cx="18"
                            cy="18"
                            r="14"
                            fill="none"
                            strokeWidth="4"
                            className="stroke-muted"
                          />
                          <circle
                            cx="18"
                            cy="18"
                            r="14"
                            fill="none"
                            strokeWidth="4"
                            strokeLinecap="round"
                            pathLength={100}
                            strokeDasharray={100}
                            strokeDashoffset={mounted ? 100 - completion : 100}
                            className={cn(
                              "transition-[stroke-dashoffset] duration-1000 ease-out",
                              completionStroke(completion),
                            )}
                          />
                        </svg>
                        <span className="text-sm">{completion}%</span>
                      </div>
                    </TableCell>
                    <TableCell className="whitespace-nowrap py-3 px-4">
                      {node.status ? (
                        <Badge className={STATUS_STYLES[node.status]}>{node.status}</Badge>
                      ) : (
                        <Badge
                          className={
                            doneCount === classCount
                              ? STATUS_STYLES.Completed
                              : SUMMARY_STYLE
                          }
                        >
                          {doneCount} of {classCount} done
                        </Badge>
                      )}
                    </TableCell>
                  </TableRow>
                );
              })}
            </TableBody>
          </Table>
        </div>
      </Card>
    </section>
  );
}

export default TreeTable;
```
