Aksara UI

Table

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

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.

ผลการเรียน ภาคเรียนที่ 1
นักเรียน
วิชา
คะแนนรวม
สถานะ
การจัดการ
สช
สมชาย ใจดีม.2/1
คณิตศาสตร์
86 / 100
กม
กมลชนก ศรีสุขม.2/1
วิทยาศาสตร์
92 / 100
ณว
ณัฐวุฒิ ทองคำม.2/2
ภาษาไทย
64 / 100
<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.

นักเรียน
ห้อง
วิชา
คะแนน
อีเมลผู้ปกครอง
เบอร์โทร
เมนู
สมชาย ใจดี
ม.2/1
คณิตศาสตร์
86
parent.somchai@example.co.th
081 234 5678
กมลชนก ศรีสุข
ม.2/1
วิทยาศาสตร์
92
kamonchanok.srisuk.guardian@example.co.th
089 876 5432
ณัฐวุฒิ ทองคำ
ม.2/2
ภาษาไทย
64
nattawut.t@example.co.th
062 345 6789
<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.

ปิดรับการแก้ไขคะแนน
นักเรียน
คะแนน
การจัดการ
สมชาย ใจดีม.2/1
86
กมลชนก ศรีสุขม.2/1
92
ณัฐวุฒิ ทองคำม.2/2
64
<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.

นักเรียน
วิชา
คะแนน
การจัดการ
<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.

หัวข้องาน
ข้อเสนอแนะจากครู
อีเมลผู้ปกครอง
การวิเคราะห์วรรณคดีไทยเรื่องขุนช้างขุนแผนตอนพลายแก้วเป็นชู้กับนางพิม
อธิบายบริบททางประวัติศาสตร์ได้ชัดเจน ควรเพิ่มตัวอย่างจากบทประพันธ์
guardian.of.a.student.with.a.very.long.address@example.co.th
โครงงานวิทยาศาสตร์
ส่งตรงเวลา
nok@example.co.th
รายงานการทดลองเรื่องการเปลี่ยนแปลงสถานะของสาร
ตารางบันทึกผลครบถ้วน กราฟอ่านง่าย
parent-team@example.co.th
เรียงความ
ยังไม่ส่ง
mali@example.co.th
<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 widthHeader paddingCell padding
800px and wider12px by 24px6px by 24px
480px to 799px8px by 16px6px by 16px
Narrower than 480px6px by 12px6px 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.

ความกว้าง 600px
นักเรียน
คะแนน
สช
สมชาย ใจดีม.2/1
86
กม
กมลชนก ศรีสุขม.2/1
92
ความกว้าง 320px
นักเรียน
คะแนน
สช
สมชาย ใจดีม.2/1
86
กม
กมลชนก ศรีสุขม.2/1
92
<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.

ห้อง
กมลชนก ศรีสุข
ม.2/1
92
สมชาย ใจดี
ม.2/1
86
พิมพ์ชนก วงศ์ใหญ่
ม.2/2
78
ณัฐวุฒิ ทองคำ
ม.2/2
64
<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.

เลือกแล้ว 1 คน

นักเรียน
วิชา
สมชาย ใจดี
คณิตศาสตร์
กมลชนก ศรีสุข
วิทยาศาสตร์
ณัฐวุฒิ ทองคำ
ภาษาไทย
พิมพ์ชนก วงศ์ใหญ่
ภาษาอังกฤษ
<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. 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.

เลือกแล้ว 0 คน

ห้อง
สมชาย ใจดี
ม.2/1
86 / 100
กมลชนก ศรีสุข
ม.2/1
92 / 100
ณัฐวุฒิ ทองคำ
ม.2/2
64 / 100
พิมพ์ชนก วงศ์ใหญ่
ม.2/2
78 / 100
ธนากร แก้วมณี
ม.2/3
71 / 100
<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

Table.Title

Prop

Type

Table.Content

Prop

Type

Table.Header

Prop

Type

Table.Body

Prop

Type

Table.Row

Prop

Type

Table.HeaderCell

Prop

Type

Table.Cell

Prop

Type

Table.CellText

Prop

Type

Table.Skeleton

Prop

Type

Edit on GitHub

On this page