# DataTable

> Structured tabular display with sortable columns, row selection, loading skeletons, and an empty-state slot. Caller owns sort and selection state; columns accept string-key or function accessors.

- Category: data-display
- Status: stable (since 1.0.0)
- A11y pattern: https://www.w3.org/WAI/ARIA/apg/patterns/grid/
- Tokens: --background-tertiary, --background-quaternary, --foreground-primary, --foreground-secondary
- Playground: https://design.freecodecamp.org/playground#data-table
- npm dependencies: `react@>=18 <20`
- Registry dependencies: [theme](https://design.freecodecamp.org/registry/theme.md)
- Files:
  - `DataTable.tsx` → `src/ui/data-table/DataTable.tsx` (raw: https://design.freecodecamp.org/registry/data-table/DataTable.tsx)
  - `data-table.css` → `src/ui/data-table/data-table.css` (raw: https://design.freecodecamp.org/registry/data-table/data-table.css)

## Install (copy source)

1. Ensure the theme is installed once per project - tokens.css + base.css imported globally, fonts available. See https://design.freecodecamp.org/registry/theme.md and https://design.freecodecamp.org/registry/starter.md.
2. Copy the files below into `src/ui/data-table/` (adjust to your project layout) and import the CSS once from your global stylesheet, e.g. `@import './ui/data-table/data-table.css';`.
3. Colors, spacing and type come from tokens - tailor the component by editing the copied source; recolour by editing tokens.css, not the component CSS.

## Usage

Use DataTable for lists with comparable fields (users, tasks,
incidents). For cards or read-heavy summaries reach for `<Panel>` or
`<DescriptionList>`. Sort and selection are controlled - the table
stays pure and lets the caller wire URL state, persistence, etc.

## Usage

```tsx
import { DataTable } from './ui/data-table/DataTable';
import { EmptyState } from './ui/empty-state/EmptyState';
const columns = [
  { id: 'name', header: 'Name', accessor: 'name', sortable: true },
  { id: 'email', header: 'Email', accessor: row => row.email },
  { id: 'status', header: 'Status', accessor: 'status', align: 'right' }
];

<DataTable
  columns={columns}
  rows={users}
  rowId={row => row.id}
  sortBy={sort}
  onSortChange={setSort}
  selection={selected}
  onSelectionChange={setSelected}
  loading={isLoading}
  emptyState={<EmptyState title='No users yet' />}
/>;
```

## Accessibility

Renders a native `<table>` - sortable headers use `aria-sort` and wrap
the label in a `<button>` so keyboard users can cycle sort direction.
Selected rows carry `data-selected="true"`. The select-all checkbox
enters `indeterminate` when some (but not all) rows are selected. Use
`caption` to name the table for assistive tech.

## Example

```tsx
import { DataTable } from './ui/data-table/DataTable';
import { useState } from 'react';

const [sortBy, setSortBy] = useState({ columnId: 'cert', direction: 'asc' });

<DataTable
  columns={[
    { id: 'cert', header: 'Cert', sortable: true },
    { id: 'hours', header: 'Hours', align: 'right' },
    { id: 'status', header: 'Status', align: 'center' }
  ]}
  rows={rows}
  sortBy={sortBy}
  onSortChange={setSortBy}
/>
```

## Props

| Prop | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `columns` | `readonly DataTableColumn<TRow>[]` | yes | - |  |
| `rows` | `readonly TRow[]` | yes | - |  |
| `rowId` | `((row: TRow) => string)` | no | `<TRow,>(row: TRow): string =>
  String((row as unknown as { id?: unknown }).id ?? '')` | Row id accessor. Defaults to `row.id`. |
| `sortBy` | `DataTableSort | null` | no | - |  |
| `onSortChange` | `((next: DataTableSort | null) => void)` | no | - |  |
| `selection` | `ReadonlySet<string>` | no | - |  |
| `onSelectionChange` | `((next: Set<string>) => void)` | no | - |  |
| `loading` | `boolean` | no | `false` |  |
| `emptyState` | `ReactNode` | no | - |  |
| `className` | `string` | no | `` |  |
| `caption` | `ReactNode` | no | - |  |
| `skeletonRows` | `number` | no | `3` | Number of skeleton rows to emit while `loading`. |

## Source: DataTable.tsx

```tsx
import React from 'react';

export type DataTableAlign = 'left' | 'right' | 'center';

export interface DataTableColumn<TRow> {
  id: string;
  header: React.ReactNode;
  /** `string` reads `row[accessor]`; function maps the row to a cell value. */
  accessor: keyof TRow | ((row: TRow) => React.ReactNode);
  sortable?: boolean;
  align?: DataTableAlign;
  width?: number | string;
}

export interface DataTableSort {
  columnId: string;
  direction: 'asc' | 'desc';
}

export interface DataTableProps<TRow> {
  columns: readonly DataTableColumn<TRow>[];
  rows: readonly TRow[];
  /** Row id accessor. Defaults to `row.id`. */
  rowId?: (row: TRow) => string;
  sortBy?: DataTableSort | null;
  onSortChange?: (next: DataTableSort | null) => void;
  selection?: ReadonlySet<string>;
  onSelectionChange?: (next: Set<string>) => void;
  loading?: boolean;
  emptyState?: React.ReactNode;
  className?: string;
  caption?: React.ReactNode;
  /** Number of skeleton rows to emit while `loading`. */
  skeletonRows?: number;
}

const defaultRowId = <TRow,>(row: TRow): string =>
  String((row as unknown as { id?: unknown }).id ?? '');

const readCell = <TRow,>(
  row: TRow,
  column: DataTableColumn<TRow>
): React.ReactNode => {
  if (typeof column.accessor === 'function') return column.accessor(row);
  return (row as unknown as Record<string, React.ReactNode>)[
    column.accessor as string
  ];
};

const nextDirection = (
  current: DataTableSort | null | undefined,
  columnId: string
): DataTableSort | null => {
  if (!current || current.columnId !== columnId) {
    return { columnId, direction: 'asc' };
  }
  if (current.direction === 'asc') {
    return { columnId, direction: 'desc' };
  }
  // Third click clears the sort - matches common table UX.
  return null;
};

const toWidth = (v: number | string | undefined): string | undefined =>
  typeof v === 'number' ? `${v}px` : v;

export const DataTable = <TRow,>({
  columns,
  rows,
  rowId = defaultRowId,
  sortBy,
  onSortChange,
  selection,
  onSelectionChange,
  loading = false,
  emptyState,
  className = '',
  caption,
  skeletonRows = 3
}: DataTableProps<TRow>): React.ReactElement => {
  const classes = ['data-table', className].filter(Boolean).join(' ');
  const hasSelection =
    selection !== undefined && onSelectionChange !== undefined;
  const totalCols = columns.length + (hasSelection ? 1 : 0);
  const allIds = rows.map(row => rowId(row));
  const allSelected =
    hasSelection && allIds.length > 0 && allIds.every(id => selection.has(id));
  const someSelected =
    hasSelection && !allSelected && allIds.some(id => selection.has(id));

  const toggleAll = (): void => {
    if (!hasSelection) return;
    const next = new Set(selection);
    if (allSelected) {
      allIds.forEach(id => next.delete(id));
    } else {
      allIds.forEach(id => next.add(id));
    }
    onSelectionChange(next);
  };
  const toggleRow = (id: string): void => {
    if (!hasSelection) return;
    const next = new Set(selection);
    if (next.has(id)) next.delete(id);
    else next.add(id);
    onSelectionChange(next);
  };

  const renderCell = (
    row: TRow,
    column: DataTableColumn<TRow>
  ): React.ReactElement => {
    const cellClasses = [
      'data-table__cell',
      column.align && column.align !== 'left'
        ? `data-table__cell--${column.align}`
        : ''
    ]
      .filter(Boolean)
      .join(' ');
    return (
      <td
        key={column.id}
        className={cellClasses}
        style={
          column.width !== undefined
            ? { width: toWidth(column.width) }
            : undefined
        }
      >
        {readCell(row, column)}
      </td>
    );
  };

  return (
    <div className={classes}>
      <table className='data-table__table'>
        {caption !== undefined && <caption>{caption}</caption>}
        <thead>
          <tr>
            {hasSelection && (
              <th scope='col' className='data-table__select-all'>
                <input
                  type='checkbox'
                  aria-label='Select all rows'
                  checked={allSelected}
                  ref={el => {
                    if (el) el.indeterminate = someSelected;
                  }}
                  onChange={toggleAll}
                />
              </th>
            )}
            {columns.map(column => {
              const sortable = column.sortable === true;
              const active = sortBy?.columnId === column.id;
              const ariaSort: 'ascending' | 'descending' | 'none' | undefined =
                sortable
                  ? active
                    ? sortBy.direction === 'asc'
                      ? 'ascending'
                      : 'descending'
                    : 'none'
                  : undefined;
              const headerClasses = [
                'data-table__header',
                column.align && column.align !== 'left'
                  ? `data-table__header--${column.align}`
                  : ''
              ]
                .filter(Boolean)
                .join(' ');
              return (
                <th
                  key={column.id}
                  scope='col'
                  className={headerClasses}
                  aria-sort={ariaSort}
                  style={
                    column.width !== undefined
                      ? { width: toWidth(column.width) }
                      : undefined
                  }
                >
                  {sortable && onSortChange !== undefined ? (
                    <button
                      type='button'
                      className='data-table__sort-btn'
                      onClick={() =>
                        onSortChange(nextDirection(sortBy, column.id))
                      }
                    >
                      <span>{column.header}</span>
                      <span
                        className='data-table__sort-indicator'
                        aria-hidden='true'
                      >
                        {active
                          ? sortBy.direction === 'asc'
                            ? '▲'
                            : '▼'
                          : '↕'}
                      </span>
                    </button>
                  ) : (
                    column.header
                  )}
                </th>
              );
            })}
          </tr>
        </thead>
        <tbody>
          {loading
            ? Array.from({ length: skeletonRows }, (_, i) => (
                <tr key={`skel-${i}`} className='data-table__skeleton'>
                  {hasSelection && (
                    <td className='data-table__cell'>
                      <span className='skeleton' aria-hidden='true' />
                    </td>
                  )}
                  {columns.map(column => (
                    <td key={column.id} className='data-table__cell'>
                      <span className='skeleton' aria-hidden='true' />
                    </td>
                  ))}
                </tr>
              ))
            : rows.length === 0
              ? [
                  <tr key='empty' className='data-table__empty-row'>
                    <td colSpan={totalCols} className='data-table__empty-cell'>
                      {emptyState}
                    </td>
                  </tr>
                ]
              : rows.map(row => {
                  const id = rowId(row);
                  const selected = hasSelection && selection.has(id);
                  return (
                    <tr
                      key={id}
                      data-row-id={id}
                      data-selected={selected ? 'true' : undefined}
                    >
                      {hasSelection && (
                        <td className='data-table__cell data-table__select-cell'>
                          <input
                            type='checkbox'
                            aria-label={`Select row ${id}`}
                            checked={selected}
                            onChange={() => toggleRow(id)}
                          />
                        </td>
                      )}
                      {columns.map(column => renderCell(row, column))}
                    </tr>
                  );
                })}
        </tbody>
      </table>
    </div>
  );
};
DataTable.displayName = 'DataTable';
```

## Source: data-table.css

```css
.data-table {
  width: 100%;
  overflow-x: auto;
  border: var(--border-width-thin) solid var(--foreground-secondary);
}
.data-table__table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--fs-sm);
  color: var(--foreground-primary);
}
.data-table__table caption {
  padding: 8px 12px;
  text-align: left;
  font-size: var(--fs-xs);
  color: var(--foreground-secondary);
  border-bottom: var(--border-width-thin) solid var(--foreground-secondary);
}
.data-table__header {
  padding: 10px 12px;
  text-align: left;
  font-family: var(--font-heading);
  font-size: var(--fs-xs);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--foreground-secondary);
  background: var(--background-tertiary);
  border-bottom: var(--border-width-thin) solid var(--foreground-secondary);
}
.data-table__header--right {
  text-align: right;
}
.data-table__header--center {
  text-align: center;
}
.data-table__select-all {
  width: 36px;
  padding: 10px 12px;
  background: var(--background-tertiary);
  border-bottom: var(--border-width-thin) solid var(--foreground-secondary);
  text-align: center;
}
.data-table__sort-btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  background: transparent;
  border: 0;
  padding: 0;
  font: inherit;
  color: inherit;
  cursor: pointer;
  text-transform: inherit;
  letter-spacing: inherit;
}
.data-table__sort-indicator {
  font-size: 10px;
  line-height: 1;
  opacity: 0.8;
}
.data-table__table tbody tr {
  border-top: var(--border-width-thin) solid var(--background-tertiary);
}
.data-table__table tbody tr[data-selected='true'] {
  background: var(--background-quaternary);
}
.data-table__cell {
  padding: 10px 12px;
  vertical-align: top;
  text-align: left;
}
.data-table__cell--right {
  text-align: right;
}
.data-table__cell--center {
  text-align: center;
}
.data-table__select-cell {
  width: 36px;
  text-align: center;
}
.data-table__skeleton .skeleton {
  height: 14px;
  width: 80%;
  display: block;
}
.data-table__empty-row {
  background: transparent;
}
.data-table__empty-cell {
  padding: 32px 16px;
  text-align: center;
  color: var(--foreground-secondary);
}
```

## HTML / vanilla variant

```html
<div class="data-table">
  <table class="data-table__table">
    <thead><tr><th class="data-table__header">Cert</th></tr></thead>
    <tbody><tr><td class="data-table__cell">Responsive Web Design</td></tr></tbody>
  </table>
</div>
```

Interactive behaviours for plain HTML come from the vanilla runtime (data-uikit-* attributes): https://design.freecodecamp.org/registry/vanilla.md - or download https://design.freecodecamp.org/cdn/uikit.global.js once and self-host it (do not hotlink).

## For coding agents

This library is distributed as copyable source, not an npm package. Start at https://design.freecodecamp.org/registry/starter.md, discover components via https://design.freecodecamp.org/llms.txt, and copy files into the consuming project. Keep token names intact; recolour by editing the copied tokens.css.
