# Table

> Table is a set of styled parts over native table elements. Your data, state or data library decides what goes in them.

Source: /docs/components/table

```tsx
import { Table } from '@aksornproject/aksara-ui'
```

## Anatomy

Table owns the look of each part and nothing else. It never receives your rows, columns or sort order. Build the rows with `.map` or a data library and drop library components such as Checkbox, Avatar, Badge, Button and Menu into the cells.

- `Table.Root` is the frame. It sets the border, the radius and the width the padding follows. `pinned`, `state`, `striped` and `mutedHeader` live here and style every part below.
- `Table.Title` is an optional heading row above the table. Give it an `id` and point `aria-labelledby` at it.
- `Table.Content` is the `<table>`, inside a scroller that moves sideways when the columns are wider than the frame. It needs `aria-label` or `aria-labelledby`.
- `Table.Header` and `Table.Body` are `<thead>` and `<tbody>`.
- `Table.Row` is a `<tr>`. It owns the hover, stripe and disabled fill.
- `Table.HeaderCell` is a `<th scope="col">`. It can show a sort button and hold a `leading` control, such as a select-all checkbox.
- `Table.Cell` is a `<td>`. Its content sits in a row with an 8px gap and a 60px minimum height.
- `Table.CellText` stacks a primary line over a smaller `supporting` line.
- `Table.Skeleton` is a placeholder shape for loading cells.

## Basic table

Each cell lays out whatever you put in it. Text wraps, and long words such as email addresses break anywhere instead of widening the column.

`hug` shrinks a column to its content on one line. Use it for badges, buttons and menus. Set it on the header cell and on the body cells of that column.

```tsx
import { Avatar, Badge, Button, Table } from '@aksornproject/aksara-ui'

const students = [
  {
    id: 's1',
    name: 'สมชาย ใจดี',
    initials: 'สช',
    room: 'ม.2/1',
    subject: 'คณิตศาสตร์',
    score: 86,
    status: 'ส่งงานครบ',
  },
  {
    id: 's2',
    name: 'กมลชนก ศรีสุข',
    initials: 'กม',
    room: 'ม.2/1',
    subject: 'วิทยาศาสตร์',
    score: 92,
    status: 'ส่งงานครบ',
  },
  {
    id: 's3',
    name: 'ณัฐวุฒิ ทองคำ',
    initials: 'ณว',
    room: 'ม.2/2',
    subject: 'ภาษาไทย',
    score: 64,
    status: 'ต้องติดตาม',
  },
]

export default function TableBasic() {
  return (
    <Table.Root className="w-full">
      <Table.Title id="grades-title">ผลการเรียน ภาคเรียนที่ 1</Table.Title>
      <Table.Content aria-labelledby="grades-title">
        <Table.Header>
          <Table.Row>
            <Table.HeaderCell>นักเรียน</Table.HeaderCell>
            <Table.HeaderCell>วิชา</Table.HeaderCell>
            <Table.HeaderCell>คะแนนรวม</Table.HeaderCell>
            <Table.HeaderCell hug>สถานะ</Table.HeaderCell>
            <Table.HeaderCell hug>การจัดการ</Table.HeaderCell>
          </Table.Row>
        </Table.Header>
        <Table.Body>
          {students.map((student) => (
            <Table.Row key={student.id}>
              <Table.Cell>
                <Avatar size="small" fallback={student.initials} alt="" />
                <Table.CellText supporting={student.room}>
                  {student.name}
                </Table.CellText>
              </Table.Cell>
              <Table.Cell>{student.subject}</Table.Cell>
              <Table.Cell>{student.score} / 100</Table.Cell>
              <Table.Cell hug>
                <Badge size="small" color="information">
                  {student.status}
                </Badge>
              </Table.Cell>
              <Table.Cell hug>
                <Button
                  size="lg"
                  color="brand"
                  variant="secondary-brand"
                  aria-label={`แก้ไข ${student.name}`}
                >
                  แก้ไข
                </Button>
                <Button
                  size="lg"
                  color="destructive"
                  variant="primary"
                  aria-label={`ลบ ${student.name}`}
                >
                  ลบ
                </Button>
              </Table.Cell>
            </Table.Row>
          ))}
        </Table.Body>
      </Table.Content>
    </Table.Root>
  )
}
```

## Pinned columns

`pinned="first"` keeps the first cell of every row in view while the table scrolls sideways. `pinned="first-last"` keeps the last cell as well. Pinned cells stay opaque and add no shadow.

`width` fixes a column in pixels. A fixed width and `hug` cannot be combined. Columns without either share the remaining space by their content.

```tsx
import { Menu, Table } from '@aksornproject/aksara-ui'

const students = [
  {
    id: 's1',
    name: 'สมชาย ใจดี',
    room: 'ม.2/1',
    subject: 'คณิตศาสตร์',
    score: 86,
    email: 'parent.somchai@example.co.th',
    phone: '081 234 5678',
  },
  {
    id: 's2',
    name: 'กมลชนก ศรีสุข',
    room: 'ม.2/1',
    subject: 'วิทยาศาสตร์',
    score: 92,
    email: 'kamonchanok.srisuk.guardian@example.co.th',
    phone: '089 876 5432',
  },
  {
    id: 's3',
    name: 'ณัฐวุฒิ ทองคำ',
    room: 'ม.2/2',
    subject: 'ภาษาไทย',
    score: 64,
    email: 'nattawut.t@example.co.th',
    phone: '062 345 6789',
  },
]

export default function TablePinned() {
  return (
    <Table.Root pinned="first-last" className="w-full">
      <Table.Content aria-label="ข้อมูลติดต่อผู้ปกครอง">
        <Table.Header>
          <Table.Row>
            <Table.HeaderCell width={192}>นักเรียน</Table.HeaderCell>
            <Table.HeaderCell width={120}>ห้อง</Table.HeaderCell>
            <Table.HeaderCell width={192}>วิชา</Table.HeaderCell>
            <Table.HeaderCell width={120}>คะแนน</Table.HeaderCell>
            <Table.HeaderCell width={249}>อีเมลผู้ปกครอง</Table.HeaderCell>
            <Table.HeaderCell width={192}>เบอร์โทร</Table.HeaderCell>
            <Table.HeaderCell hug>เมนู</Table.HeaderCell>
          </Table.Row>
        </Table.Header>
        <Table.Body>
          {students.map((student) => (
            <Table.Row key={student.id}>
              <Table.Cell>{student.name}</Table.Cell>
              <Table.Cell>{student.room}</Table.Cell>
              <Table.Cell>{student.subject}</Table.Cell>
              <Table.Cell>{student.score}</Table.Cell>
              <Table.Cell>{student.email}</Table.Cell>
              <Table.Cell>{student.phone}</Table.Cell>
              <Table.Cell hug>
                <Menu.Root>
                  <Menu.Trigger
                    size="small"
                    iconOnly
                    aria-label={`จัดการ ${student.name}`}
                  />
                  <Menu.Portal>
                    <Menu.Positioner align="end">
                      <Menu.Popup>
                        <Menu.Item>แก้ไข</Menu.Item>
                        <Menu.Item>ทำสำเนา</Menu.Item>
                        <Menu.Item destructive>ลบ</Menu.Item>
                      </Menu.Popup>
                    </Menu.Positioner>
                  </Menu.Portal>
                </Menu.Root>
              </Table.Cell>
            </Table.Row>
          ))}
        </Table.Body>
      </Table.Content>
    </Table.Root>
  )
}
```

## States

`state="disabled"` greys every cell and the title, and disables the sort buttons. It does not reach into your content, so pass `disabled` to your own checkboxes, buttons and menus too.

```tsx
import { Button, Checkbox, Table } from '@aksornproject/aksara-ui'

const students = [
  { id: 's1', name: 'สมชาย ใจดี', room: 'ม.2/1', score: 86 },
  { id: 's2', name: 'กมลชนก ศรีสุข', room: 'ม.2/1', score: 92 },
  { id: 's3', name: 'ณัฐวุฒิ ทองคำ', room: 'ม.2/2', score: 64 },
]

export default function TableDisabled() {
  return (
    <Table.Root state="disabled" className="w-full">
      <Table.Title id="disabled-title">ปิดรับการแก้ไขคะแนน</Table.Title>
      <Table.Content aria-labelledby="disabled-title">
        <Table.Header>
          <Table.Row>
            <Table.HeaderCell
              leading={<Checkbox aria-label="เลือกทั้งหมด" disabled />}
            >
              นักเรียน
            </Table.HeaderCell>
            <Table.HeaderCell>คะแนน</Table.HeaderCell>
            <Table.HeaderCell hug>การจัดการ</Table.HeaderCell>
          </Table.Row>
        </Table.Header>
        <Table.Body>
          {students.map((student) => (
            <Table.Row key={student.id}>
              <Table.Cell>
                <Checkbox aria-label={`เลือก ${student.name}`} disabled />
                <Table.CellText supporting={student.room}>
                  {student.name}
                </Table.CellText>
              </Table.Cell>
              <Table.Cell>{student.score}</Table.Cell>
              <Table.Cell hug>
                <Button
                  size="lg"
                  color="brand"
                  variant="secondary-brand"
                  disabled
                >
                  แก้ไข
                </Button>
              </Table.Cell>
            </Table.Row>
          ))}
        </Table.Body>
      </Table.Content>
    </Table.Root>
  )
}
```

`state="loading"` sets `aria-busy` on the table and replaces each header label with a skeleton bar. The labels stay available to screen readers. Body cells keep whatever you render, so put `Table.Skeleton` in them. `shape` picks the placeholder. `checkbox` is 20px, `square` is 24px for short text, `bar` is 100 by 24 for wider content and `circle` is 36px for an avatar.

```tsx
import { Table } from '@aksornproject/aksara-ui'

const columns = ['นักเรียน', 'วิชา', 'คะแนน', 'การจัดการ']

export default function TableLoading() {
  return (
    <Table.Root state="loading" className="w-full">
      <Table.Content aria-label="ผลการเรียน">
        <Table.Header>
          <Table.Row>
            {columns.map((column) => (
              <Table.HeaderCell key={column}>{column}</Table.HeaderCell>
            ))}
          </Table.Row>
        </Table.Header>
        <Table.Body>
          {[1, 2, 3].map((row) => (
            <Table.Row key={row}>
              <Table.Cell>
                <Table.Skeleton shape="checkbox" />
                <Table.Skeleton shape="circle" />
                <Table.Skeleton shape="bar" />
              </Table.Cell>
              <Table.Cell>
                <Table.Skeleton shape="bar" />
              </Table.Cell>
              <Table.Cell>
                <Table.Skeleton shape="square" />
              </Table.Cell>
              <Table.Cell>
                <Table.Skeleton shape="bar" />
              </Table.Cell>
            </Table.Row>
          ))}
        </Table.Body>
      </Table.Content>
    </Table.Root>
  )
}
```

## Striped rows and muted header

`striped` fills every even body row. Text keeps its full contrast. `mutedHeader` fills the header cells with the muted background. They are independent and can be combined.

```tsx
import { Table } from '@aksornproject/aksara-ui'

const assignments = [
  {
    id: 'a1',
    topic:
      'การวิเคราะห์วรรณคดีไทยเรื่องขุนช้างขุนแผนตอนพลายแก้วเป็นชู้กับนางพิม',
    note: 'อธิบายบริบททางประวัติศาสตร์ได้ชัดเจน ควรเพิ่มตัวอย่างจากบทประพันธ์',
    email: 'guardian.of.a.student.with.a.very.long.address@example.co.th',
  },
  {
    id: 'a2',
    topic: 'โครงงานวิทยาศาสตร์',
    note: 'ส่งตรงเวลา',
    email: 'nok@example.co.th',
  },
  {
    id: 'a3',
    topic: 'รายงานการทดลองเรื่องการเปลี่ยนแปลงสถานะของสาร',
    note: 'ตารางบันทึกผลครบถ้วน กราฟอ่านง่าย',
    email: 'parent-team@example.co.th',
  },
  {
    id: 'a4',
    topic: 'เรียงความ',
    note: 'ยังไม่ส่ง',
    email: 'mali@example.co.th',
  },
]

export default function TableStriped() {
  return (
    <Table.Root striped mutedHeader className="w-full">
      <Table.Content aria-label="งานที่ได้รับมอบหมาย">
        <Table.Header>
          <Table.Row>
            <Table.HeaderCell>หัวข้องาน</Table.HeaderCell>
            <Table.HeaderCell>ข้อเสนอแนะจากครู</Table.HeaderCell>
            <Table.HeaderCell>อีเมลผู้ปกครอง</Table.HeaderCell>
          </Table.Row>
        </Table.Header>
        <Table.Body>
          {assignments.map((assignment) => (
            <Table.Row key={assignment.id}>
              <Table.Cell>{assignment.topic}</Table.Cell>
              <Table.Cell>{assignment.note}</Table.Cell>
              <Table.Cell>{assignment.email}</Table.Cell>
            </Table.Row>
          ))}
        </Table.Body>
      </Table.Content>
    </Table.Root>
  )
}
```

## Density

Padding follows the width of the table itself, not the screen. There is no device or size prop. The same table is roomy in a wide page and compact in a side panel or a narrow card.

| Table width         | Header padding | Cell padding |
| ------------------- | -------------- | ------------ |
| 800px and wider     | 12px by 24px   | 6px by 24px  |
| 480px to 799px      | 8px by 16px    | 6px by 16px  |
| Narrower than 480px | 6px by 12px    | 6px by 12px  |

Padding reads vertical by horizontal. Body cells keep a 60px minimum height at every width. Choose which columns to show at each width yourself. The table never hides a column on its own.

The demos on this page are narrower than 800px, so they use the middle padding. The demo below fixes `Table.Root` at 600px and 320px to compare the middle and narrow padding.

```tsx
import { Avatar, Table } from '@aksornproject/aksara-ui'

const students = [
  { id: 's1', name: 'สมชาย ใจดี', initials: 'สช', room: 'ม.2/1', score: 86 },
  { id: 's2', name: 'กมลชนก ศรีสุข', initials: 'กม', room: 'ม.2/1', score: 92 },
]

function Grades({ width }: { width: number }) {
  return (
    <Table.Root style={{ width }}>
      <Table.Title id={`density-${width}`}>ความกว้าง {width}px</Table.Title>
      <Table.Content aria-labelledby={`density-${width}`}>
        <Table.Header>
          <Table.Row>
            <Table.HeaderCell>นักเรียน</Table.HeaderCell>
            <Table.HeaderCell hug>คะแนน</Table.HeaderCell>
          </Table.Row>
        </Table.Header>
        <Table.Body>
          {students.map((student) => (
            <Table.Row key={student.id}>
              <Table.Cell>
                <Avatar size="small" fallback={student.initials} alt="" />
                <Table.CellText supporting={student.room}>
                  {student.name}
                </Table.CellText>
              </Table.Cell>
              <Table.Cell hug>{student.score}</Table.Cell>
            </Table.Row>
          ))}
        </Table.Body>
      </Table.Content>
    </Table.Root>
  )
}

export default function TableDensity() {
  return (
    <div className="flex w-full flex-col gap-spacing-24">
      <Grades width={600} />
      <Grades width={320} />
    </div>
  )
}
```

## Sorting

Table does not sort. Pass `sort` to a header cell to wrap its label in a button with a sort icon. `sort` takes `'ascending'`, `'descending'` or `'unsorted'`. `onSort` is required with `sort` and fires on press. Compute the next direction and reorder the rows yourself.

`aria-sort` appears only on the column sorted `ascending` or `descending`. An `'unsorted'` column shows the neutral icon and no `aria-sort`.

```tsx
import { Table, type TableSort } from '@aksornproject/aksara-ui'
import { useState } from 'react'

const students = [
  { id: 's1', name: 'สมชาย ใจดี', room: 'ม.2/1', score: 86 },
  { id: 's2', name: 'กมลชนก ศรีสุข', room: 'ม.2/1', score: 92 },
  { id: 's3', name: 'ณัฐวุฒิ ทองคำ', room: 'ม.2/2', score: 64 },
  { id: 's4', name: 'พิมพ์ชนก วงศ์ใหญ่', room: 'ม.2/2', score: 78 },
]

type SortKey = 'name' | 'score'

const NEXT_SORT = {
  unsorted: 'ascending',
  ascending: 'descending',
  descending: 'unsorted',
} as const satisfies Record<TableSort, TableSort>

const SIGN = { ascending: 1, descending: -1, unsorted: 0 }

export default function TableSorting() {
  const [sort, setSort] = useState<{ key: SortKey; direction: TableSort }>({
    key: 'score',
    direction: 'descending',
  })
  const directionOf = (key: SortKey) =>
    sort.key === key ? sort.direction : 'unsorted'
  const cycle = (key: SortKey) =>
    setSort({ key, direction: NEXT_SORT[directionOf(key)] })
  const rows = [...students].sort(
    (a, b) =>
      SIGN[sort.direction] *
      (sort.key === 'score'
        ? a.score - b.score
        : a.name.localeCompare(b.name, 'th')),
  )

  return (
    <Table.Root className="w-full">
      <Table.Content aria-label="คะแนนสอบกลางภาค">
        <Table.Header>
          <Table.Row>
            <Table.HeaderCell
              sort={directionOf('name')}
              onSort={() => cycle('name')}
            >
              ชื่อ
            </Table.HeaderCell>
            <Table.HeaderCell>ห้อง</Table.HeaderCell>
            <Table.HeaderCell
              sort={directionOf('score')}
              onSort={() => cycle('score')}
            >
              คะแนน
            </Table.HeaderCell>
          </Table.Row>
        </Table.Header>
        <Table.Body>
          {rows.map((student) => (
            <Table.Row key={student.id}>
              <Table.Cell>{student.name}</Table.Cell>
              <Table.Cell>{student.room}</Table.Cell>
              <Table.Cell>{student.score}</Table.Cell>
            </Table.Row>
          ))}
        </Table.Body>
      </Table.Content>
    </Table.Root>
  )
}
```

## Selection

Table does not track selection either. Keep the selected IDs in your own state and render a Checkbox in each row. Put the select-all checkbox in the header cell's `leading` slot, which sits outside the sort button. Set `indeterminate` when some, but not all, rows are selected.

```tsx
import { Checkbox, Table } from '@aksornproject/aksara-ui'
import { useState } from 'react'

const students = [
  { id: 's1', name: 'สมชาย ใจดี', subject: 'คณิตศาสตร์' },
  { id: 's2', name: 'กมลชนก ศรีสุข', subject: 'วิทยาศาสตร์' },
  { id: 's3', name: 'ณัฐวุฒิ ทองคำ', subject: 'ภาษาไทย' },
  { id: 's4', name: 'พิมพ์ชนก วงศ์ใหญ่', subject: 'ภาษาอังกฤษ' },
]

export default function TableSelection() {
  const [selected, setSelected] = useState<ReadonlySet<string>>(
    () => new Set(['s2']),
  )
  const all = selected.size === students.length
  const toggle = (id: string, checked: boolean) =>
    setSelected((current) => {
      const next = new Set(current)
      if (checked) next.add(id)
      else next.delete(id)
      return next
    })

  return (
    <div className="flex w-full flex-col gap-spacing-16">
      <p aria-live="polite">เลือกแล้ว {selected.size} คน</p>
      <Table.Root>
        <Table.Content aria-label="เลือกนักเรียน">
          <Table.Header>
            <Table.Row>
              <Table.HeaderCell
                leading={
                  <Checkbox
                    aria-label="เลือกทั้งหมด"
                    checked={all}
                    indeterminate={selected.size > 0 && !all}
                    onCheckedChange={(checked) =>
                      setSelected(
                        new Set(checked ? students.map((s) => s.id) : []),
                      )
                    }
                  />
                }
              >
                นักเรียน
              </Table.HeaderCell>
              <Table.HeaderCell>วิชา</Table.HeaderCell>
            </Table.Row>
          </Table.Header>
          <Table.Body>
            {students.map((student) => (
              <Table.Row key={student.id}>
                <Table.Cell>
                  <Checkbox
                    aria-label={`เลือก ${student.name}`}
                    checked={selected.has(student.id)}
                    onCheckedChange={(checked) => toggle(student.id, checked)}
                  />
                  {student.name}
                </Table.Cell>
                <Table.Cell>{student.subject}</Table.Cell>
              </Table.Row>
            ))}
          </Table.Body>
        </Table.Content>
      </Table.Root>
    </div>
  )
}
```

## Use with a data library

The parts take plain props, so any data library can drive them. This example uses [TanStack Table](https://tanstack.com/table). It holds the sort and selection state. The parts render its models.

- Map `getHeaderGroups()` to `Table.Row` and `Table.HeaderCell`, and `getRowModel().rows` to `Table.Row` and `Table.Cell`.
- TanStack reports a direction as `'asc'`, `'desc'` or `false`. Map it to `'ascending'`, `'descending'` or `'unsorted'` for `sort`, and pass the column's toggle handler to `onSort`.
- Drive the checkboxes from `getIsSelected()`, `getIsAllRowsSelected()` and `getIsSomeRowsSelected()`.

The library is not a dependency of this package. Install it in your app.

```tsx
import { Checkbox, Table, type TableSort } from '@aksornproject/aksara-ui'
import {
  createColumnHelper,
  createSortedRowModel,
  rowSelectionFeature,
  rowSortingFeature,
  tableFeatures,
  useTable,
  type SortDirection,
} from '@tanstack/react-table'

type Student = { id: string; name: string; room: string; score: number }

const students: Student[] = [
  { id: 's1', name: 'สมชาย ใจดี', room: 'ม.2/1', score: 86 },
  { id: 's2', name: 'กมลชนก ศรีสุข', room: 'ม.2/1', score: 92 },
  { id: 's3', name: 'ณัฐวุฒิ ทองคำ', room: 'ม.2/2', score: 64 },
  { id: 's4', name: 'พิมพ์ชนก วงศ์ใหญ่', room: 'ม.2/2', score: 78 },
  { id: 's5', name: 'ธนากร แก้วมณี', room: 'ม.2/3', score: 71 },
]

const features = tableFeatures({
  rowSortingFeature,
  rowSelectionFeature,
  sortedRowModel: createSortedRowModel(),
})

const helper = createColumnHelper<typeof features, Student>()

const columns = helper.columns([
  helper.accessor('name', {
    header: 'ชื่อ',
    sortFn: (a, b, id) =>
      a.getValue<string>(id).localeCompare(b.getValue<string>(id), 'th'),
  }),
  helper.accessor('room', { header: 'ห้อง', enableSorting: false }),
  helper.accessor('score', {
    header: 'คะแนน',
    cell: (info) => `${info.getValue()} / 100`,
  }),
])

const SORT = {
  asc: 'ascending',
  desc: 'descending',
} as const satisfies Record<SortDirection, TableSort>

const toTableSort = (direction: false | SortDirection): TableSort =>
  direction ? SORT[direction] : 'unsorted'

export default function TableTanStack() {
  const table = useTable({
    features,
    columns,
    data: students,
    getRowId: (row) => row.id,
  })
  const allSelected = table.getIsAllRowsSelected()

  return (
    <div className="flex w-full flex-col gap-spacing-16">
      <p aria-live="polite">เลือกแล้ว {table.getSelectedRowIds().length} คน</p>
      <Table.Root>
        <Table.Content aria-label="คะแนนนักเรียน">
          <Table.Header>
            {table.getHeaderGroups().map((group) => (
              <Table.Row key={group.id}>
                <Table.HeaderCell
                  hug
                  leading={
                    <Checkbox
                      aria-label="เลือกทั้งหมด"
                      checked={allSelected}
                      indeterminate={
                        table.getIsSomeRowsSelected() && !allSelected
                      }
                      onCheckedChange={(checked) =>
                        table.toggleAllRowsSelected(checked)
                      }
                    />
                  }
                />
                {group.headers.map((header) =>
                  header.column.getCanSort() ? (
                    <Table.HeaderCell
                      key={header.id}
                      sort={toTableSort(header.column.getIsSorted())}
                      onSort={(event) =>
                        header.column.getToggleSortingHandler()?.(event)
                      }
                    >
                      <table.FlexRender header={header} />
                    </Table.HeaderCell>
                  ) : (
                    <Table.HeaderCell key={header.id}>
                      <table.FlexRender header={header} />
                    </Table.HeaderCell>
                  ),
                )}
              </Table.Row>
            ))}
          </Table.Header>
          <Table.Body>
            {table.getRowModel().rows.map((row) => (
              <Table.Row key={row.id}>
                <Table.Cell hug>
                  <Checkbox
                    aria-label={`เลือก ${row.original.name}`}
                    checked={row.getIsSelected()}
                    onCheckedChange={(checked) => row.toggleSelected(checked)}
                  />
                </Table.Cell>
                {row.getAllCells().map((cell) => (
                  <Table.Cell key={cell.id}>
                    <table.FlexRender cell={cell} />
                  </Table.Cell>
                ))}
              </Table.Row>
            ))}
          </Table.Body>
        </Table.Content>
      </Table.Root>
    </div>
  )
}
```

## Accessibility

- `Table.Content` is a native `<table>`, so screen readers announce rows, columns and header cells. TypeScript rejects it without `aria-label` or `aria-labelledby`.
- The scroller around the table is a focusable region with the same name, so keyboard users can scroll it with the arrow keys.
- Header cells are `<th scope="col">`. The sort control is a real `<button>` and the column gets `aria-sort` while sorted.
- Name each row control after its row, such as `aria-label="เลือก สมชาย ใจดี"`. Repeated buttons such as Edit are otherwise indistinguishable.
- A loading table has `aria-busy`. Skeletons are `aria-hidden`.
- Table is not a grid. Tab moves through the controls inside the cells and the arrow keys do not move between cells.

## API

### Table.Root

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `pinned` | `"first" \| "first-last" \| "none"` | `'none'` | Keeps the first, or the first and last, cell of every row in view while the table scrolls sideways. |
| `state` | `"default" \| "disabled" \| "loading"` | `'default'` | `disabled` greys every cell; pass `disabled` to your own controls too. `loading` marks the table busy and swaps header labels for skeletons; render `Table.Skeleton` in body cells. |
| `striped` | `boolean` | `false` | Fills every even body row. |
| `mutedHeader` | `boolean` | `false` | Fills the header cells with the muted background. |
| `className` | `string` |  | Classes merged last with `cn()`. |

### Table.Title

| Prop | Type | Description |
| --- | --- | --- |
| `className` | `string` | Classes merged last with `cn()`. |

### Table.Content

| Prop | Type | Description |
| --- | --- | --- |
| `aria-label` | `string` | Names the table. Pass this or `aria-labelledby`. |
| `aria-labelledby` | `string` | The ID of the element that names the table, such as `Table.Title`. Pass this or `aria-label`. |
| `className` | `string` | Classes merged last onto the `<table>`, not the scroller. |

### Table.Header

| Prop | Type | Description |
| --- | --- | --- |
| `className` | `string` | Classes merged last with `cn()`. |

### Table.Body

| Prop | Type | Description |
| --- | --- | --- |
| `className` | `string` | Classes merged last with `cn()`. |

### Table.Row

| Prop | Type | Description |
| --- | --- | --- |
| `className` | `string` | Classes merged last with `cn()`. |

### Table.HeaderCell

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` |  | The column label. Hidden visually, but still announced, while the table is loading. |
| `width` | `number` |  | A fixed column width in px. Not accepted with `hug`. |
| `hug` | `boolean` | `false` | Not accepted with `width`. Shrinks the column to its content on one line, for buttons, badges and menus. |
| `sort` | `"ascending" \| "descending" \| "unsorted"` |  | Shows the sort glyph and wraps the label in a button. `aria-sort` is set only for `ascending` and `descending`. |
| `onSort` | `MouseEventHandler<HTMLButtonElement>` |  | Called when the label button is pressed. Compute and pass the next `sort` yourself. Requires `sort`. |
| `leading` | `ReactNode` |  | Content before the label and outside the sort button, such as a select-all `Checkbox`. |
| `className` | `string` |  | Classes merged last onto the `<th>`. |

### Table.Cell

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `width` | `number` |  | A fixed column width in px. Not accepted with `hug`. |
| `hug` | `boolean` | `false` | Not accepted with `width`. Shrinks the column to its content on one line, for buttons, badges and menus. |
| `className` | `string` |  | Classes merged last onto the `<td>`. |

### Table.CellText

| Prop | Type | Description |
| --- | --- | --- |
| `supporting` | `ReactNode` | A smaller second line, such as a class or an email. |
| `className` | `string` | Classes merged last with `cn()`. |

### Table.Skeleton

| Prop | Type | Description |
| --- | --- | --- |
| `shape` (required) | `"bar" \| "checkbox" \| "circle" \| "square"` | `square` 24px for text, `bar` 100 by 24 for wide content, `circle` 36px for an avatar, `checkbox` 20px. |
| `className` | `string` | Classes merged last with `cn()`. |