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.Rootis the frame. It sets the border, the radius and the width the padding follows.pinned,state,stripedandmutedHeaderlive here and style every part below.Table.Titleis an optional heading row above the table. Give it anidand pointaria-labelledbyat it.Table.Contentis the<table>, inside a scroller that moves sideways when the columns are wider than the frame. It needsaria-labeloraria-labelledby.Table.HeaderandTable.Bodyare<thead>and<tbody>.Table.Rowis a<tr>. It owns the hover, stripe and disabled fill.Table.HeaderCellis a<th scope="col">. It can show a sort button and hold aleadingcontrol, such as a select-all checkbox.Table.Cellis a<td>. Its content sits in a row with an 8px gap and a 60px minimum height.Table.CellTextstacks a primary line over a smallersupportingline.Table.Skeletonis 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.
นักเรียน | วิชา | คะแนนรวม | สถานะ | การจัดการ |
|---|---|---|---|---|
สมชาย ใจดีม.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 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.
นักเรียน | คะแนน |
|---|---|
สมชาย ใจดีม.2/1 | 86 |
กมลชนก ศรีสุขม.2/1 | 92 |
นักเรียน | คะแนน |
|---|---|
สมชาย ใจดีม.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()toTable.RowandTable.HeaderCell, andgetRowModel().rowstoTable.RowandTable.Cell. - TanStack reports a direction as
'asc','desc'orfalse. Map it to'ascending','descending'or'unsorted'forsort, and pass the column's toggle handler toonSort. - Drive the checkboxes from
getIsSelected(),getIsAllRowsSelected()andgetIsSomeRowsSelected().
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.Contentis a native<table>, so screen readers announce rows, columns and header cells. TypeScript rejects it withoutaria-labeloraria-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 getsaria-sortwhile 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 arearia-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