# Table

> Sortable, selectable table with a sticky header and a row hover beam.

Category: Data Display. Docs: https://neonkit.heyaadi.com/components/table

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/table.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/table`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

Builds on: [Icons](https://neonkit.heyaadi.com/components/icons.md). The CLI installs them too.

## Usage

```tsx
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

<Table aria-label="Deployments">
  <TableHeader>
    <TableRow>
      <TableHead sortDirection="desc" onSort={toggleSort}>Created</TableHead>
      <TableHead align="right">Duration</TableHead>
    </TableRow>
  </TableHeader>
  <TableBody>
    <TableRow selected>
      <TableCell>4m ago</TableCell>
      <TableCell align="right">1m 12s</TableCell>
    </TableRow>
  </TableBody>
</Table>
```

## Examples

### Deployments

Sortable headers, row selection and status badges. Hover a row and a soft beam crosses it; selected rows get a tint and a lit left edge.

```tsx
"use client";

import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";

type Status = "ready" | "building" | "queued" | "error" | "canceled";

type Deployment = {
  id: string;
  message: string;
  sha: string;
  branch: string;
  status: Status;
  /** Build time in seconds. */
  duration: number | null;
  minutesAgo: number;
};

const deployments: Deployment[] = [
  { id: "dpl_1", message: "Cache edge config between deploys", sha: "8f3a2c1", branch: "main", status: "building", duration: null, minutesAgo: 0 },
  { id: "dpl_2", message: "Redesign the billing settings page", sha: "3b8e51f", branch: "feat/billing-v2", status: "queued", duration: null, minutesAgo: 1 },
  { id: "dpl_3", message: "Fix flaky checkout test", sha: "a1f9c2e", branch: "main", status: "ready", duration: 72, minutesAgo: 14 },
  { id: "dpl_4", message: "Add OTP input to sign-in", sha: "7c3d10b", branch: "feat/otp-input", status: "ready", duration: 95, minutesAgo: 38 },
  { id: "dpl_5", message: "Bump next to 16.4.0", sha: "c93e4a8", branch: "deps/next-16-4", status: "error", duration: 41, minutesAgo: 62 },
  { id: "dpl_6", message: "Tighten rate limits on search", sha: "5d2f61b", branch: "fix/search-limits", status: "ready", duration: 66, minutesAgo: 125 },
  { id: "dpl_7", message: "Translate onboarding into German", sha: "e0b7a93", branch: "i18n/de", status: "canceled", duration: 12, minutesAgo: 190 },
];

const statuses = {
  ready: { label: "Ready", color: "green", pulse: false, order: 3 },
  building: { label: "Building", color: "amber", pulse: true, order: 1 },
  queued: { label: "Queued", color: "white", pulse: false, order: 2 },
  error: { label: "Error", color: "red", pulse: false, order: 0 },
  canceled: { label: "Canceled", color: "white", pulse: false, order: 4 },
} as const;

type SortKey = "message" | "status" | "duration" | "created";
type Sort = { key: SortKey; direction: "asc" | "desc" };

const compare: Record<SortKey, (a: Deployment, b: Deployment) => number> = {
  message: (a, b) => a.message.localeCompare(b.message),
  status: (a, b) => statuses[a.status].order - statuses[b.status].order,
  duration: (a, b) => (a.duration ?? Infinity) - (b.duration ?? Infinity),
  created: (a, b) => b.minutesAgo - a.minutesAgo,
};

function formatDuration(seconds: number | null) {
  if (seconds === null) return "—";
  const minutes = Math.floor(seconds / 60);
  return minutes ? `${minutes}m ${String(seconds % 60).padStart(2, "0")}s` : `${seconds}s`;
}

function formatAge(minutes: number) {
  if (minutes < 1) return "just now";
  if (minutes < 60) return `${minutes}m ago`;
  return `${Math.floor(minutes / 60)}h ago`;
}

export default function TableDemo() {
  const [sort, setSort] = React.useState<Sort>({ key: "created", direction: "desc" });
  const [selected, setSelected] = React.useState<Set<string>>(() => new Set(["dpl_4"]));

  const rows = React.useMemo(() => {
    const sorted = [...deployments].sort(compare[sort.key]);
    return sort.direction === "asc" ? sorted : sorted.reverse();
  }, [sort]);

  function sortBy(key: SortKey) {
    setSort((current) =>
      current.key === key
        ? { key, direction: current.direction === "asc" ? "desc" : "asc" }
        : { key, direction: "asc" },
    );
  }

  function toggle(id: string, on: boolean) {
    setSelected((current) => {
      const next = new Set(current);
      if (on) next.add(id);
      else next.delete(id);
      return next;
    });
  }

  const allSelected = selected.size === deployments.length;
  const direction = (key: SortKey) => (sort.key === key ? sort.direction : false);

  return (
    <div className="grid w-full max-w-3xl gap-3">
      <div className="flex items-baseline justify-between gap-4 px-1">
        <h3 className="text-sm font-medium text-white/90">Deployments</h3>
        <p aria-live="polite" className="text-xs text-white/50">
          {selected.size > 0 ? `${selected.size} of ${deployments.length} selected` : `${deployments.length} deployments`}
        </p>
      </div>
      <Table aria-label="Deployments">
        <TableHeader>
          <TableRow>
            <TableHead className="w-12 pr-0">
              <Checkbox
                className="flex w-fit"
                aria-label="Select all deployments"
                checked={allSelected}
                indeterminate={selected.size > 0 && !allSelected}
                onCheckedChange={(on) => setSelected(on ? new Set(deployments.map((d) => d.id)) : new Set())}
              />
            </TableHead>
            <TableHead sortDirection={direction("message")} onSort={() => sortBy("message")}>
              Commit
            </TableHead>
            <TableHead sortDirection={direction("status")} onSort={() => sortBy("status")}>
              Status
            </TableHead>
            <TableHead align="right" sortDirection={direction("duration")} onSort={() => sortBy("duration")}>
              Duration
            </TableHead>
            <TableHead align="right" sortDirection={direction("created")} onSort={() => sortBy("created")}>
              Created
            </TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {rows.map((deployment) => {
            const status = statuses[deployment.status];
            const isSelected = selected.has(deployment.id);
            return (
              <TableRow
                key={deployment.id}
                selected={isSelected}
                interactive
                onClick={(event) => {
                  // The checkbox handles its own clicks.
                  if ((event.target as HTMLElement).closest("label")) return;
                  toggle(deployment.id, !isSelected);
                }}
              >
                <TableCell className="pr-0">
                  <Checkbox
                    className="flex w-fit"
                    aria-label={`Select ${deployment.sha}`}
                    checked={isSelected}
                    onCheckedChange={(on) => toggle(deployment.id, on)}
                  />
                </TableCell>
                <TableCell>
                  {/* A floor on the width: on a phone the table scrolls instead of cutting every message to a word. */}
                  <div className="grid min-w-[13.5rem] max-w-[18rem] gap-0.5">
                    <span className="truncate font-medium text-white/90">{deployment.message}</span>
                    <span className="flex min-w-0 whitespace-nowrap font-mono text-xs text-white/50">
                      <span className="truncate">{deployment.branch}</span>
                      <span className="shrink-0">&nbsp;· {deployment.sha}</span>
                    </span>
                  </div>
                </TableCell>
                <TableCell>
                  <Badge color={status.color} dot pulse={status.pulse}>
                    {status.label}
                  </Badge>
                </TableCell>
                <TableCell align="right" className="whitespace-nowrap font-mono text-xs text-white/60">
                  {formatDuration(deployment.duration)}
                </TableCell>
                <TableCell align="right" className="whitespace-nowrap text-white/50">
                  {formatAge(deployment.minutesAgo)}
                </TableCell>
              </TableRow>
            );
          })}
        </TableBody>
      </Table>
    </div>
  );
}
```

### Data table

Sorting, selection with Checkbox and Pagination wired together. Selection survives paging and sorting; the header checkbox covers the current page.

```tsx
"use client";

import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Pagination } from "@/components/ui/pagination";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";

type Status = "paid" | "open" | "overdue" | "draft";

type Invoice = {
  id: string;
  customer: string;
  status: Status;
  /** Days since Jan 1, 2026 (UTC). */
  due: number;
  amount: number;
};

const customers = [
  "Kestrel Health",
  "Orbital Freight",
  "Lumen Analytics",
  "Parallel Works",
  "Riverbend Clinic",
  "Sable Systems",
  "Tessellate",
  "Umbra Audio",
  "Vantage Legal",
  "Quarry & Co",
];

const TODAY = 283; // Oct 11, 2026
const PAGE_SIZE = 6;

const initialInvoices: Invoice[] = Array.from({ length: 24 }, (_, i) => {
  const due = 300 - i * 5;
  const status: Status = due > TODAY ? (i % 4 === 0 ? "draft" : "open") : i % 5 === 2 ? "overdue" : "paid";
  return {
    id: `INV-${2041 - i}`,
    customer: customers[(i * 3) % customers.length],
    status,
    due,
    amount: ((i * 7919) % 8900) + 450 + ((i * 37) % 4) * 0.25,
  };
});

const statuses = {
  paid: { label: "Paid", color: "green" },
  open: { label: "Open", color: "blue" },
  overdue: { label: "Overdue", color: "red" },
  draft: { label: "Draft", color: "white" },
} as const;

const currency = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
const day = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
const formatDue = (days: number) => day.format(new Date(Date.UTC(2026, 0, 1 + days)));

type SortKey = "id" | "customer" | "due" | "amount";
type Sort = { key: SortKey; direction: "asc" | "desc" };

const compare: Record<SortKey, (a: Invoice, b: Invoice) => number> = {
  id: (a, b) => a.id.localeCompare(b.id),
  customer: (a, b) => a.customer.localeCompare(b.customer),
  due: (a, b) => a.due - b.due,
  amount: (a, b) => a.amount - b.amount,
};

export default function TableDataTable() {
  const [invoices, setInvoices] = React.useState(initialInvoices);
  const [sort, setSort] = React.useState<Sort>({ key: "due", direction: "desc" });
  const [page, setPage] = React.useState(1);
  const [selected, setSelected] = React.useState<Set<string>>(() => new Set());

  const sorted = React.useMemo(() => {
    const rows = [...invoices].sort(compare[sort.key]);
    return sort.direction === "asc" ? rows : rows.reverse();
  }, [invoices, sort]);

  const pageCount = Math.ceil(sorted.length / PAGE_SIZE);
  const start = (page - 1) * PAGE_SIZE;
  const rows = sorted.slice(start, start + PAGE_SIZE);
  const selectedOnPage = rows.filter((invoice) => selected.has(invoice.id)).length;

  function sortBy(key: SortKey) {
    setSort((current) =>
      current.key === key
        ? { key, direction: current.direction === "asc" ? "desc" : "asc" }
        : { key, direction: "asc" },
    );
    setPage(1);
  }

  function setChecked(ids: string[], on: boolean) {
    setSelected((current) => {
      const next = new Set(current);
      for (const id of ids) {
        if (on) next.add(id);
        else next.delete(id);
      }
      return next;
    });
  }

  function markPaid() {
    setInvoices((current) =>
      current.map((invoice) => (selected.has(invoice.id) ? { ...invoice, status: "paid" } : invoice)),
    );
    setSelected(new Set());
  }

  const direction = (key: SortKey) => (sort.key === key ? sort.direction : false);

  return (
    <div className="grid w-full max-w-3xl gap-3">
      <div className="flex h-8 items-center justify-between gap-4 px-1">
        <p aria-live="polite" className="text-sm text-white/55">
          {selected.size > 0 ? (
            <span className="text-white/90">{selected.size} selected</span>
          ) : (
            `${invoices.length} invoices`
          )}
        </p>
        {selected.size > 0 && (
          <div className="flex gap-2">
            <Button size="sm" variant="ghost" onClick={() => setSelected(new Set())}>
              Clear
            </Button>
            <Button size="sm" variant="soft" onClick={markPaid}>
              Mark as paid
            </Button>
          </div>
        )}
      </div>

      <Table aria-label="Invoices">
        <TableHeader>
          <TableRow>
            <TableHead className="w-12 pr-0">
              <Checkbox
                aria-label="Select all invoices on this page"
                checked={selectedOnPage === rows.length}
                indeterminate={selectedOnPage > 0 && selectedOnPage < rows.length}
                onCheckedChange={(on) => setChecked(rows.map((invoice) => invoice.id), on)}
              />
            </TableHead>
            <TableHead sortDirection={direction("id")} onSort={() => sortBy("id")}>
              Invoice
            </TableHead>
            <TableHead sortDirection={direction("customer")} onSort={() => sortBy("customer")}>
              Customer
            </TableHead>
            <TableHead>Status</TableHead>
            <TableHead sortDirection={direction("due")} onSort={() => sortBy("due")}>
              Due
            </TableHead>
            <TableHead align="right" sortDirection={direction("amount")} onSort={() => sortBy("amount")}>
              Amount
            </TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {rows.map((invoice) => {
            const status = statuses[invoice.status];
            const isSelected = selected.has(invoice.id);
            return (
              <TableRow key={invoice.id} selected={isSelected}>
                <TableCell className="pr-0">
                  <Checkbox
                    aria-label={`Select ${invoice.id}`}
                    checked={isSelected}
                    onCheckedChange={(on) => setChecked([invoice.id], on)}
                  />
                </TableCell>
                <TableCell className="font-mono text-xs text-white/90">{invoice.id}</TableCell>
                <TableCell className="whitespace-nowrap">{invoice.customer}</TableCell>
                <TableCell>
                  <Badge color={status.color} size="sm">
                    {status.label}
                  </Badge>
                </TableCell>
                <TableCell
                  className={
                    invoice.status === "overdue" ? "whitespace-nowrap text-[#ff4d5e]" : "whitespace-nowrap text-white/55"
                  }
                >
                  {formatDue(invoice.due)}
                </TableCell>
                <TableCell align="right" className="font-medium text-white/90">
                  {currency.format(invoice.amount)}
                </TableCell>
              </TableRow>
            );
          })}
        </TableBody>
      </Table>

      <div className="flex flex-wrap items-center justify-between gap-3 px-1">
        <p className="text-xs tabular-nums text-white/50">
          {start + 1}–{start + rows.length} of {sorted.length}
        </p>
        <Pagination size="sm" page={page} onPageChange={setPage} pageCount={pageCount} />
      </div>
    </div>
  );
}
```

### Sticky header in a Scroll Area

The header pins to the nearest scroll container. Here the ScrollArea scrolls, so the table's own container is set to overflow-visible and edge fades are off to keep the header crisp.

```tsx
import { ScrollArea } from "@/components/ui/scroll-area";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";

const endpoints = [
  { method: "GET", path: "/api/projects" },
  { method: "GET", path: "/api/projects/halcyon-web/deployments" },
  { method: "POST", path: "/api/auth/session" },
  { method: "GET", path: "/api/search?q=table" },
  { method: "PATCH", path: "/api/billing/invoices/INV-2038" },
  { method: "GET", path: "/api/teams/core/members" },
  { method: "POST", path: "/api/webhooks/github" },
  { method: "DELETE", path: "/api/tokens/tok_9c2f" },
  { method: "GET", path: "/r/table.json" },
];

const codes = [200, 200, 304, 200, 201, 200, 429, 200, 204, 404, 200, 500, 200, 401];

// Deterministic, so the server and client render the same rows.
const requests = Array.from({ length: 36 }, (_, i) => {
  const endpoint = endpoints[(i * 4) % endpoints.length];
  const seconds = 59 - ((i * 7) % 60);
  return {
    id: i,
    time: `14:${String(59 - Math.floor((i * 7) / 60)).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`,
    ...endpoint,
    status: codes[i % codes.length],
    latency: 18 + ((i * 53) % 340),
  };
});

const statusClass = (status: number) =>
  status >= 500 ? "text-[#ff4d5e]" : status >= 400 ? "text-[#fbbf24]" : "text-white/60";

export default function TableStickyHeader() {
  return (
    <ScrollArea
      orientation="both"
      fade={false}
      aria-label="Request log"
      className="h-80 w-full max-w-2xl rounded-xl border border-white/10 bg-white/[0.02]"
    >
      <Table
        stickyHeader
        density="compact"
        containerClassName="overflow-visible rounded-none border-0 bg-transparent"
      >
        <TableHeader>
          <TableRow>
            <TableHead>Time</TableHead>
            <TableHead>Method</TableHead>
            <TableHead>Path</TableHead>
            <TableHead align="right">Status</TableHead>
            <TableHead align="right" className="pr-5">
              Latency
            </TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {requests.map((request) => (
            <TableRow key={request.id}>
              <TableCell className="font-mono text-xs text-white/50">{request.time}</TableCell>
              <TableCell className="font-mono text-xs text-white/70">{request.method}</TableCell>
              <TableCell className="whitespace-nowrap font-mono text-xs text-white/90">{request.path}</TableCell>
              <TableCell align="right" className={`font-mono text-xs ${statusClass(request.status)}`}>
                {request.status}
              </TableCell>
              <TableCell align="right" className="pr-5 font-mono text-xs text-white/55">
                {request.latency} ms
              </TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </ScrollArea>
  );
}
```

### Compact, with footer and caption

```tsx
import {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableFooter,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";

const usage = [
  { resource: "Function invocations", included: "1M", used: "1.38M", cost: 7.6 },
  { resource: "Edge requests", included: "10M", used: "8.2M", cost: 0 },
  { resource: "Fast data transfer", included: "1 TB", used: "1.24 TB", cost: 36 },
  { resource: "Build minutes", included: "6,000", used: "4,410", cost: 0 },
  { resource: "Image optimizations", included: "5,000", used: "7,920", cost: 14.6 },
];

const currency = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
const total = usage.reduce((sum, row) => sum + row.cost, 0);

export default function TableUsage() {
  return (
    <Table density="compact" containerClassName="max-w-xl">
      <TableCaption>Billing period Oct 1 – Oct 31, 2026. Usage refreshes every hour.</TableCaption>
      <TableHeader>
        <TableRow>
          <TableHead>Resource</TableHead>
          <TableHead align="right">Included</TableHead>
          <TableHead align="right">Used</TableHead>
          <TableHead align="right">Overage</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {usage.map((row) => (
          <TableRow key={row.resource}>
            <TableCell className="whitespace-nowrap text-white/90">{row.resource}</TableCell>
            <TableCell align="right" className="whitespace-nowrap text-white/50">
              {row.included}
            </TableCell>
            <TableCell align="right" className="whitespace-nowrap">
              {row.used}
            </TableCell>
            <TableCell align="right" className={row.cost > 0 ? "whitespace-nowrap text-white/90" : "text-white/45"}>
              {row.cost > 0 ? currency.format(row.cost) : "—"}
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
      <TableFooter>
        <TableRow>
          <TableHead colSpan={3}>Total overage</TableHead>
          <TableCell align="right">{currency.format(total)}</TableCell>
        </TableRow>
      </TableFooter>
    </Table>
  );
}
```

## API reference

### Table

Renders a <table> inside a framed container that scrolls sideways when the table is too wide. Native table attributes, className and ref go to the <table>.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `density` | `"compact" \| "normal"` | `"normal"` | Row height and cell padding. |
| `stickyHeader` | `boolean` | `false` | Pins the header row to the top of the nearest scroll container. Give the container a max-height, or make it overflow-visible inside an outer scroller. |
| `containerClassName` | `string` | — | Classes for the container, e.g. max-h-96, or border-0 to drop the frame. |
| `color` | `NeonColor` | `inherited` | Selection tint, lit edge, beam and sort arrow. Falls back to --neon-accent, then cyan. |

### TableHead

A <th>. scope is col in the header and row elsewhere.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `onSort` | `() => void` | — | Renders the label as a sort button. Work out the next direction in your handler. |
| `sortDirection` | `"asc" \| "desc" \| false` | `false` | Sets aria-sort and points the arrow. Unsorted columns show the arrow on hover. |
| `align` | `"left" \| "center" \| "right"` | `"left"` | Right-aligned sortable heads put the arrow before the label so numbers line up. |

### TableRow

A <tr>. Body rows light up on hover; header and footer rows don't.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `selected` | `boolean` | — | Neon tint and a lit left edge. Also sets aria-selected. |
| `interactive` | `boolean` | `false` | Pointer cursor and a pressed state for rows that respond to clicks. |

### TableCell

A <td>.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `align` | `"left" \| "center" \| "right"` | `"left"` | Text alignment. Numbers use tabular figures throughout the table. |

### TableHeader, TableBody, TableFooter, TableCaption

<thead>, <tbody>, <tfoot> and <caption>, with native attributes. The caption sits under the table.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `className` | `string` | — | Extra classes. |

## Accessibility

- It's a real HTML table, so screen readers announce rows, columns and headers. Name it with aria-label or a TableCaption.
- Sortable headers are buttons inside the <th>; the sorted column gets aria-sort, so the direction is announced with the header.
- Row selection belongs on a real control: put a Checkbox with an aria-label in the row. Clicking the row itself is a pointer shortcut, not the only way.
- interactive only styles the row. If a row navigates, put a link in its primary cell so keyboard users can reach it.
- The hover beam is transform-only and is skipped with reduced motion.

## Source

`components/ui/table.tsx`

```tsx
"use client";

import * as React from "react";
import { ArrowUp } from "@/components/ui/icons";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

// The row is positioned, so pseudo-elements of its first cell can span the
// whole row: ::before is the lit edge of a selected row, ::after the beam that
// crosses a hovered row. They hang off a cell because browsers wrap
// pseudo-elements of the row itself in an extra anonymous cell. The beam is 40%
// of the row and travels 150% of its own width, so it never leaves the row.
const css = `
.nk-table-row > :first-child::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: 2px;
  pointer-events: none;
  background: rgb(var(--neon));
  box-shadow: 0 0 2px rgb(var(--neon) / var(--neon-glow, 1)), 0 0 10px rgb(var(--neon) / calc(0.7 * var(--neon-glow, 1)));
  opacity: 0;
  transform: scaleY(0.4);
  transition: transform 220ms cubic-bezier(0.23, 1, 0.32, 1), opacity 160ms ease-out;
}
.nk-table-row[data-state="selected"] > :first-child::before { opacity: 1; transform: none; }
.nk-table-row > :first-child::after {
  content: "";
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: 40%;
  pointer-events: none;
  opacity: 0;
  background:
    linear-gradient(90deg, transparent, rgb(var(--neon) / 0.5), transparent) bottom / 100% 1px no-repeat,
    linear-gradient(90deg, transparent, rgb(var(--neon) / 0.06), transparent);
}
.nk-table-row:hover > :first-child::after { animation: nk-table-beam 1.1s cubic-bezier(0.45, 0, 0.25, 1); }
@keyframes nk-table-beam {
  0% { transform: translateX(0); opacity: 0; }
  25%, 70% { opacity: 1; }
  100% { transform: translateX(150%); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .nk-table-row > :first-child::before { transition: none; }
  .nk-table-row:hover > :first-child::after { animation: none; }
}
`;

const densities = {
  compact: { head: "h-9 px-3", cell: "px-3 py-2 text-[13px] leading-5", caption: "px-3 py-2.5" },
  normal: { head: "h-10 px-4", cell: "px-4 py-3 text-sm leading-5", caption: "px-4 py-3" },
} as const;

const aligns = {
  left: "text-left",
  center: "text-center",
  right: "text-right",
} as const;

type Density = keyof typeof densities;
type Align = keyof typeof aligns;
type Section = "head" | "body" | "foot";

const TableContext = React.createContext<{ density: Density; stickyHeader: boolean }>({
  density: "normal",
  stickyHeader: false,
});
const SectionContext = React.createContext<Section>("body");

export interface TableProps extends Omit<React.TableHTMLAttributes<HTMLTableElement>, "color"> {
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  /** Pins the header row while the nearest scroll container scrolls. */
  stickyHeader?: boolean;
  density?: Density;
  /** Classes for the framed scroll container around the table. */
  containerClassName?: string;
}

const Table = React.forwardRef<HTMLTableElement, TableProps>(
  (
    {
      className,
      containerClassName,
      color,
      stickyHeader = false,
      density = "normal",
      ...props
    },
    ref,
  ) => {
    const context = React.useMemo(() => ({ density, stickyHeader }), [density, stickyHeader]);
    // A table that overflows its frame scrolls; only then does the frame take a tab stop, so the keyboard can too.
    const frameRef = React.useRef<HTMLDivElement>(null);
    const [scrolls, setScrolls] = React.useState(false);
    React.useEffect(() => {
      const frame = frameRef.current;
      const table = frame?.querySelector("table");
      if (!frame || !table || typeof ResizeObserver === "undefined") return;
      const measure = () =>
        setScrolls(frame.scrollWidth > frame.clientWidth + 1 || frame.scrollHeight > frame.clientHeight + 1);
      const observer = new ResizeObserver(measure);
      observer.observe(frame);
      observer.observe(table);
      return () => observer.disconnect();
    }, []);

    return (
      <TableContext.Provider value={context}>
        <div
          ref={frameRef}
          data-density={density}
          tabIndex={scrolls ? 0 : undefined}
          style={neonVars(color)}
          className={cn(
            "[--neon:var(--neon-accent,34_211_238)]",
            "relative w-full overflow-auto rounded-xl border border-white/10 bg-white/[0.02]",
            "outline-none focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]",
            containerClassName,
          )}
        >
          <style>{css}</style>
          <table
            ref={ref}
            className={cn(
              "w-full border-separate border-spacing-0 text-left tabular-nums text-white/75 [caption-side:bottom]",
              className,
            )}
            {...props}
          />
        </div>
      </TableContext.Provider>
    );
  },
);
Table.displayName = "Table";

const TableCaption = React.forwardRef<
  HTMLTableCaptionElement,
  React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => {
  const { density } = React.useContext(TableContext);
  return (
    <caption
      ref={ref}
      className={cn(
        "border-t border-white/[0.06] text-left text-xs leading-5 text-white/55",
        densities[density].caption,
        className,
      )}
      {...props}
    />
  );
});
TableCaption.displayName = "TableCaption";

const TableHeader = React.forwardRef<
  HTMLTableSectionElement,
  React.HTMLAttributes<HTMLTableSectionElement>
>((props, ref) => (
  <SectionContext.Provider value="head">
    <thead ref={ref} {...props} />
  </SectionContext.Provider>
));
TableHeader.displayName = "TableHeader";

const TableBody = React.forwardRef<
  HTMLTableSectionElement,
  React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
  <SectionContext.Provider value="body">
    <tbody
      ref={ref}
      className={cn("[&>tr:last-child>*]:border-b-0", className)}
      {...props}
    />
  </SectionContext.Provider>
));
TableBody.displayName = "TableBody";

const TableFooter = React.forwardRef<
  HTMLTableSectionElement,
  React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
  <SectionContext.Provider value="foot">
    <tfoot
      ref={ref}
      className={cn("bg-white/[0.02] font-medium text-white/90", className)}
      {...props}
    />
  </SectionContext.Provider>
));
TableFooter.displayName = "TableFooter";

export interface TableRowProps extends React.HTMLAttributes<HTMLTableRowElement> {
  /** Neon tint and a lit left edge. Also sets `aria-selected`. */
  selected?: boolean;
  /** Pointer cursor and a pressed state, for rows that respond to clicks. */
  interactive?: boolean;
}

const TableRow = React.forwardRef<HTMLTableRowElement, TableRowProps>(
  ({ className, selected, interactive = false, ...props }, ref) => {
    const section = React.useContext(SectionContext);
    const body = section === "body";

    return (
      <tr
        ref={ref}
        data-state={selected ? "selected" : undefined}
        aria-selected={body && selected !== undefined ? selected : undefined}
        className={cn(
          body && [
            "nk-table-row relative transition-colors duration-150 ease-out",
            selected
              ? "bg-[rgb(var(--neon)/0.06)] hover:bg-[rgb(var(--neon)/0.08)]"
              : "hover:bg-white/[0.025]",
            interactive &&
              "cursor-pointer active:bg-white/[0.04] focus-visible:[outline:2px_solid_rgb(var(--neon)/0.6)] focus-visible:[outline-offset:-2px]",
          ],
          className,
        )}
        {...props}
      />
    );
  },
);
TableRow.displayName = "TableRow";

export interface TableHeadProps
  extends Omit<React.ThHTMLAttributes<HTMLTableCellElement>, "align"> {
  /** Current sort of this column. Sets `aria-sort` and points the arrow. */
  sortDirection?: "asc" | "desc" | false;
  /** Makes the header a sort button. Work out the next direction in your handler. */
  onSort?: () => void;
  align?: Align;
}

const TableHead = React.forwardRef<HTMLTableCellElement, TableHeadProps>(
  ({ className, sortDirection = false, onSort, align = "left", children, ...props }, ref) => {
    const { density, stickyHeader } = React.useContext(TableContext);
    const section = React.useContext(SectionContext);
    const head = section === "head";
    const sorted = sortDirection === "asc" || sortDirection === "desc";

    return (
      <th
        ref={ref}
        scope={head ? "col" : "row"}
        aria-sort={
          sortDirection === "asc" ? "ascending" : sortDirection === "desc" ? "descending" : undefined
        }
        className={cn(
          "whitespace-nowrap align-middle font-medium",
          aligns[align],
          head
            ? cn(
                "border-b border-white/10 text-xs text-white/50",
                densities[density].head,
                stickyHeader
                  ? "sticky top-0 z-10 bg-[#0e0f14] shadow-[0_8px_16px_-10px_rgb(0_0_0/0.9)]"
                  : "bg-white/[0.02]",
              )
            : cn(
                "border-b border-white/[0.06] text-white/90",
                densities[density].cell,
                section === "foot" && "border-b-0 border-t border-t-white/10",
              ),
          className,
        )}
        {...props}
      >
        {onSort ? (
          <button
            type="button"
            onClick={onSort}
            className={cn(
              "group/sort -mx-2 inline-flex h-7 cursor-pointer select-none items-center gap-1.5 rounded-md px-2 font-medium outline-none",
              "transition-colors duration-150 ease-out hover:bg-white/[0.04] hover:text-white/85",
              "focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]",
              align === "right" && "flex-row-reverse",
              sorted && "text-white/90",
            )}
          >
            {children}
            <ArrowUp
              aria-hidden
              strokeWidth={2.25}
              className={cn(
                "size-3.5 shrink-0",
                "transition-[transform,rotate,opacity,color,filter] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none",
                sorted
                  ? "text-[rgb(var(--neon))] opacity-100 [filter:drop-shadow(0_0_4px_rgb(var(--neon)/CALC(0.8*var(--neon-glow,1))))]"
                  : "opacity-0 group-hover/sort:opacity-50 group-focus-visible/sort:opacity-50",
                sortDirection === "desc" && "rotate-180",
              )}
            />
          </button>
        ) : (
          children
        )}
      </th>
    );
  },
);
TableHead.displayName = "TableHead";

export interface TableCellProps
  extends Omit<React.TdHTMLAttributes<HTMLTableCellElement>, "align"> {
  align?: Align;
}

const TableCell = React.forwardRef<HTMLTableCellElement, TableCellProps>(
  ({ className, align = "left", ...props }, ref) => {
    const { density } = React.useContext(TableContext);
    const section = React.useContext(SectionContext);

    return (
      <td
        ref={ref}
        className={cn(
          "align-middle",
          densities[density].cell,
          aligns[align],
          section === "foot" ? "border-t border-white/10" : "border-b border-white/[0.06]",
          className,
        )}
        {...props}
      />
    );
  },
);
TableCell.displayName = "TableCell";

export {
  Table,
  TableCaption,
  TableHeader,
  TableBody,
  TableFooter,
  TableRow,
  TableHead,
  TableCell,
};
```
