Table
A data table: records in rows and columns, compared by scanning down a column. It wraps a native <table> and gives it the system's styles, sortable columns, and a scroll box the keyboard can reach when the table is too wide for the screen. Use it for records people compare (invoices, members, deployments); use aihio-grid of cards for a few summaries, and never a table for layout. For more rows than a page can hold, show one page at a time with aihio-pagination, or use aihio-data-grid.
Intents
tabular-data
Variants
Every value of the attributes that change how it looks. The values come from the schema.
density
Row spacing. compact fits more rows on screen, for dense data that people scan.
| Service | Requests |
|---|---|
| api-gateway | 12,480 |
| billing-worker | 3,215 |
density="default"| Service | Requests |
|---|---|
| api-gateway | 12,480 |
| billing-worker | 3,215 |
density="compact"Examples
Each one rendered live, over the markup that produces it.
Records with a caption
Each column is named by a <th> in <thead>, and the cell that names each row is a <th scope="row">. data-numeric lines figures up by place value, and a <tfoot> holds the totals.
| Invoice | Customer | Status | Amount |
|---|---|---|---|
| INV-1042 | Northwind Traders | €1,250.00 | |
| INV-1043 | Fabrikam | €980.50 | |
| INV-1044 | Contoso | €12,400.00 | |
| Total | €14,630.50 | ||
Show code · 39 lines
<aihio-table>
<table>
<caption>Invoices</caption>
<thead>
<tr>
<th scope="col">Invoice</th>
<th scope="col">Customer</th>
<th scope="col">Status</th>
<th scope="col" data-numeric>Amount</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">INV-1042</th>
<td>Northwind Traders</td>
<td><aihio-badge variant="success">Paid</aihio-badge></td>
<td data-numeric>€1,250.00</td>
</tr>
<tr>
<th scope="row">INV-1043</th>
<td>Fabrikam</td>
<td><aihio-badge variant="warning">Due</aihio-badge></td>
<td data-numeric>€980.50</td>
</tr>
<tr>
<th scope="row">INV-1044</th>
<td>Contoso</td>
<td><aihio-badge variant="destructive">Overdue</aihio-badge></td>
<td data-numeric>€12,400.00</td>
</tr>
</tbody>
<tfoot>
<tr>
<th scope="row" colspan="3">Total</th>
<td data-numeric>€14,630.50</td>
</tr>
</tfoot>
</table>
</aihio-table>
Sortable columns
data-sortable makes a header a sort button. The rows load sorted by the header with aria-sort. A <time datetime> sorts by its machine-readable value, and data-sort-value gives a priority an order its words do not have.
| Service | Priority | Deployed | Duration (s) |
|---|---|---|---|
| api-gateway | High | 42.5 | |
| billing-worker | Low | 118.0 | |
| search-indexer | Medium | 7.25 |
Show code · 33 lines
<aihio-table>
<table>
<caption>Deployments</caption>
<thead>
<tr>
<th scope="col" data-sortable="service">Service</th>
<th scope="col" data-sortable="priority">Priority</th>
<th scope="col" data-sortable="deployed" aria-sort="descending">Deployed</th>
<th scope="col" data-sortable="duration" data-numeric>Duration (s)</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">api-gateway</th>
<td data-sort-value="1">High</td>
<td><time datetime="2026-10-05T14:20">5 Oct, 14:20</time></td>
<td data-numeric>42.5</td>
</tr>
<tr>
<th scope="row">billing-worker</th>
<td data-sort-value="3">Low</td>
<td><time datetime="2026-10-04T09:05">4 Oct, 09:05</time></td>
<td data-numeric>118.0</td>
</tr>
<tr>
<th scope="row">search-indexer</th>
<td data-sort-value="2">Medium</td>
<td><time datetime="2026-09-29T17:45">29 Sep, 17:45</time></td>
<td data-numeric>7.25</td>
</tr>
</tbody>
</table>
</aihio-table>
Compact, with a sticky header
density="compact" fits more rows on screen, and sticky-header keeps the column names in view as they scroll. --aihio-table-max-height sets how tall the box grows first.
| Time | Method | Path | Status | Duration (ms) |
|---|---|---|---|---|
| GET | /api/projects | 200 | 38 | |
| GET | /api/projects/42 | 200 | 21 | |
| POST | /api/deployments | 201 | 412 | |
| GET | /api/deployments/97 | 200 | 17 | |
| PATCH | /api/projects/42 | 200 | 64 | |
| GET | /api/members | 200 | 29 | |
| DELETE | /api/tokens/7 | 204 | 33 | |
| GET | /api/billing | 503 | 1204 | |
| GET | /api/billing | 200 | 88 | |
| POST | /api/invites | 422 | 45 | |
| POST | /api/invites | 201 | 97 | |
| GET | /api/audit | 200 | 143 |
Show code · 100 lines
<aihio-table density="compact" sticky-header style="--aihio-table-max-height: 16rem">
<table>
<caption>Requests</caption>
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Method</th>
<th scope="col">Path</th>
<th scope="col" data-numeric>Status</th>
<th scope="col" data-numeric>Duration (ms)</th>
</tr>
</thead>
<tbody>
<tr>
<td><time datetime="2026-10-06T09:41:02">09:41:02</time></td>
<td>GET</td>
<td>/api/projects</td>
<td data-numeric>200</td>
<td data-numeric>38</td>
</tr>
<tr>
<td><time datetime="2026-10-06T09:41:03">09:41:03</time></td>
<td>GET</td>
<td>/api/projects/42</td>
<td data-numeric>200</td>
<td data-numeric>21</td>
</tr>
<tr>
<td><time datetime="2026-10-06T09:41:07">09:41:07</time></td>
<td>POST</td>
<td>/api/deployments</td>
<td data-numeric>201</td>
<td data-numeric>412</td>
</tr>
<tr>
<td><time datetime="2026-10-06T09:41:09">09:41:09</time></td>
<td>GET</td>
<td>/api/deployments/97</td>
<td data-numeric>200</td>
<td data-numeric>17</td>
</tr>
<tr>
<td><time datetime="2026-10-06T09:41:12">09:41:12</time></td>
<td>PATCH</td>
<td>/api/projects/42</td>
<td data-numeric>200</td>
<td data-numeric>64</td>
</tr>
<tr>
<td><time datetime="2026-10-06T09:41:15">09:41:15</time></td>
<td>GET</td>
<td>/api/members</td>
<td data-numeric>200</td>
<td data-numeric>29</td>
</tr>
<tr>
<td><time datetime="2026-10-06T09:41:18">09:41:18</time></td>
<td>DELETE</td>
<td>/api/tokens/7</td>
<td data-numeric>204</td>
<td data-numeric>33</td>
</tr>
<tr>
<td><time datetime="2026-10-06T09:41:21">09:41:21</time></td>
<td>GET</td>
<td>/api/billing</td>
<td data-numeric>503</td>
<td data-numeric>1204</td>
</tr>
<tr>
<td><time datetime="2026-10-06T09:41:22">09:41:22</time></td>
<td>GET</td>
<td>/api/billing</td>
<td data-numeric>200</td>
<td data-numeric>88</td>
</tr>
<tr>
<td><time datetime="2026-10-06T09:41:30">09:41:30</time></td>
<td>POST</td>
<td>/api/invites</td>
<td data-numeric>422</td>
<td data-numeric>45</td>
</tr>
<tr>
<td><time datetime="2026-10-06T09:41:31">09:41:31</time></td>
<td>POST</td>
<td>/api/invites</td>
<td data-numeric>201</td>
<td data-numeric>97</td>
</tr>
<tr>
<td><time datetime="2026-10-06T09:41:36">09:41:36</time></td>
<td>GET</td>
<td>/api/audit</td>
<td data-numeric>200</td>
<td data-numeric>143</td>
</tr>
</tbody>
</table>
</aihio-table>
Sorted by your code
manual-sort leaves the rows to you: listen for aihio-sort and render them sorted by event.detail.column and event.detail.direction, as a framework re-rendering from state or a request to the server does. The table still marks the header and announces the sort.
| Name | Plan | Seats |
|---|---|---|
| Contoso | Business | 120 |
| Fabrikam | Team | 18 |
| Northwind Traders | Enterprise | 740 |
Show code · 29 lines
<aihio-table manual-sort>
<table>
<caption>Customers</caption>
<thead>
<tr>
<th scope="col" data-sortable="name" aria-sort="ascending">Name</th>
<th scope="col" data-sortable="plan">Plan</th>
<th scope="col" data-sortable="seats" data-numeric>Seats</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Contoso</th>
<td>Business</td>
<td data-numeric>120</td>
</tr>
<tr>
<th scope="row">Fabrikam</th>
<td>Team</td>
<td data-numeric>18</td>
</tr>
<tr>
<th scope="row">Northwind Traders</th>
<td>Enterprise</td>
<td data-numeric>740</td>
</tr>
</tbody>
</table>
</aihio-table>
Links and row actions
The cell that names each row links to it, and each row's menu is named for its row, so a screen reader can tell the menus apart.
| Name | Role | Actions |
|---|---|---|
| Ada Lovelace | Owner |
|
| Grace Hopper | Admin |
|
Show code · 36 lines
<aihio-table>
<table>
<caption>Team members</caption>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Role</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row"><a href="/team/ada">Ada Lovelace</a></th>
<td>Owner</td>
<td>
<aihio-dropdown align="end">
<aihio-button slot="trigger" variant="ghost" size="icon" aria-label="Actions for Ada Lovelace">⋯</aihio-button>
<aihio-dropdown-item>Change role</aihio-dropdown-item>
<aihio-dropdown-item>Remove from team</aihio-dropdown-item>
</aihio-dropdown>
</td>
</tr>
<tr>
<th scope="row"><a href="/team/grace">Grace Hopper</a></th>
<td>Admin</td>
<td>
<aihio-dropdown align="end">
<aihio-button slot="trigger" variant="ghost" size="icon" aria-label="Actions for Grace Hopper">⋯</aihio-button>
<aihio-dropdown-item>Change role</aihio-dropdown-item>
<aihio-dropdown-item>Remove from team</aihio-dropdown-item>
</aihio-dropdown>
</td>
</tr>
</tbody>
</table>
</aihio-table>
Mistakes
Each one beside its fix, with what aihio lint says about it. The prompt fragment gives agents the
same pairs, and the build fails if the linter stops catching one or a fix stops passing.
An unnamed table is announced only as "table".
On a narrow screen it also scrolls inside a box with no name. Give it a <caption>, or point aria-labelledby at the heading above it.
Don't
<aihio-table>
<table>
<thead>
<tr>
<th scope="col">Invoice</th>
<th scope="col" data-numeric>Amount</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">INV-1042</th>
<td data-numeric>€1,250.00</td>
</tr>
</tbody>
</table>
</aihio-table>
aihio lint reports
-
table-accessible-nameerror
Do
| Invoice | Amount |
|---|---|
| INV-1042 | €1,250.00 |
<aihio-table>
<table>
<caption>Invoices</caption>
<thead>
<tr>
<th scope="col">Invoice</th>
<th scope="col" data-numeric>Amount</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">INV-1042</th>
<td data-numeric>€1,250.00</td>
</tr>
</tbody>
</table>
</aihio-table>
Bold text in an ordinary cell looks like a header but is not one.
A screen reader moving down a column cannot say which column it is in. Put the column names in <th> cells in a <thead>.
Don't
<aihio-table>
<table>
<caption>Invoices</caption>
<tr>
<td><strong>Invoice</strong></td>
<td><strong>Amount</strong></td>
</tr>
<tr>
<td>INV-1042</td>
<td>€1,250.00</td>
</tr>
</table>
</aihio-table>
aihio lint reports
-
table-header-cellserror
Do
| Invoice | Amount |
|---|---|
| INV-1042 | €1,250.00 |
<aihio-table>
<table>
<caption>Invoices</caption>
<thead>
<tr>
<th scope="col">Invoice</th>
<th scope="col" data-numeric>Amount</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">INV-1042</th>
<td data-numeric>€1,250.00</td>
</tr>
</tbody>
</table>
</aihio-table>
A header that sorts on click cannot be reached with the keyboard, and never says which way it sorts. data-sortable gives the header a real button, sets aria-sort, and announces the new order.
Don't
<aihio-table>
<table>
<caption>Invoices</caption>
<thead>
<tr>
<th scope="col" onclick="sortBy('invoice')">Invoice</th>
<th scope="col" onclick="sortBy('amount')" data-numeric>Amount</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">INV-1042</th>
<td data-numeric>€1,250.00</td>
</tr>
</tbody>
</table>
</aihio-table>
aihio lint reports
-
table-click-handlererror
Do
| Invoice | Amount |
|---|---|
| INV-1042 | €1,250.00 |
<aihio-table>
<table>
<caption>Invoices</caption>
<thead>
<tr>
<th scope="col" data-sortable="invoice">Invoice</th>
<th scope="col" data-sortable="amount" data-numeric>Amount</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">INV-1042</th>
<td data-numeric>€1,250.00</td>
</tr>
</tbody>
</table>
</aihio-table>
A row that opens on click is not a link.
The keyboard cannot reach it, a screen reader does not announce it, and it cannot be opened in a new tab. Link the cell that names the row.
Don't
<aihio-table>
<table>
<caption>Invoices</caption>
<thead>
<tr>
<th scope="col">Invoice</th>
<th scope="col" data-numeric>Amount</th>
</tr>
</thead>
<tbody>
<tr onclick="location.href = '/invoices/1042'">
<th scope="row">INV-1042</th>
<td data-numeric>€1,250.00</td>
</tr>
</tbody>
</table>
</aihio-table>
aihio lint reports
-
table-click-handlererror
Do
| Invoice | Amount |
|---|---|
| INV-1042 | €1,250.00 |
<aihio-table>
<table>
<caption>Invoices</caption>
<thead>
<tr>
<th scope="col">Invoice</th>
<th scope="col" data-numeric>Amount</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row"><a href="/invoices/1042">INV-1042</a></th>
<td data-numeric>€1,250.00</td>
</tr>
</tbody>
</table>
</aihio-table>
There are no row or cell components. aihio-table wraps a native <table>, whose rows and cells are what a screen reader navigates and what the HTML parser keeps in order.
Don't
<aihio-table>
<aihio-table-header>
<aihio-table-row>
<aihio-table-head>Invoice</aihio-table-head>
</aihio-table-row>
</aihio-table-header>
<aihio-table-body>
<aihio-table-row>
<aihio-table-cell>INV-1042</aihio-table-cell>
</aihio-table-row>
</aihio-table-body>
</aihio-table>
aihio lint reports
-
missing-required-childerror -
invalid-childerror -
invalid-childerror -
unknown-componenterror Suggests<thead> -
unknown-componenterror Suggests<tr> -
unknown-componenterror Suggests<th> -
unknown-componenterror Suggests<tbody> -
unknown-componenterror Suggests<tr> -
unknown-componenterror Suggests<td>
Do
| Invoice |
|---|
| INV-1042 |
<aihio-table>
<table>
<caption>Invoices</caption>
<thead>
<tr>
<th scope="col">Invoice</th>
</tr>
</thead>
<tbody>
<tr>
<td>INV-1042</td>
</tr>
</tbody>
</table>
</aihio-table>
Nothing reads a sortable attribute on a <th>.
The table reads data-sortable, and makes that header a sort button.
Don't
<aihio-table>
<table>
<caption>Invoices</caption>
<thead>
<tr>
<th scope="col" sortable>Invoice</th>
<th scope="col" sortable data-numeric>Amount</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">INV-1042</th>
<td data-numeric>€1,250.00</td>
</tr>
</tbody>
</table>
</aihio-table>
aihio lint reports
-
table-sortable-headererror Suggestsdata-sortable -
table-sortable-headererror Suggestsdata-sortable
Do
| Invoice | Amount |
|---|---|
| INV-1042 | €1,250.00 |
<aihio-table>
<table>
<caption>Invoices</caption>
<thead>
<tr>
<th scope="col" data-sortable>Invoice</th>
<th scope="col" data-sortable data-numeric>Amount</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">INV-1042</th>
<td data-numeric>€1,250.00</td>
</tr>
</tbody>
</table>
</aihio-table>
API
Every attribute, property, method, and event the schema declares for aihio-table.
Attributes
density-
one of
defaultcompactdefaultdefault - Row spacing. compact fits more rows on screen, for dense data that people scan.
sticky-header-
boolean
default
false - Keeps the header in view while the rows scroll. The table scrolls inside its own box, which grows to
--aihio-table-max-height(min(32rem, 75dvh) unless you set it) before its rows scroll. manual-sort-
boolean
default
false - Leaves the order of the rows to your code. A click on a sortable header still marks it with aria-sort, announces it, and fires
aihio-sort, but the rows stay as rendered: sort them yourself, as a framework re-rendering from state or a request to the server does. Without it the table reorders its own rows. loading-
boolean
default
false - Marks the rows as stale while new ones load, such as after a sort sent to the server: they are dimmed, and the table has aria-busy.
sort-ascending-text-
string
default
Sorted by {column}, ascending - Announced after sorting a column ascending, with {column} for the header's text. For localisation (e.g. "Lajiteltu: {column}, nouseva").
sort-descending-text-
string
default
Sorted by {column}, descending - Announced after sorting a column descending, with {column} for the header's text.
Properties
table-
HTMLTableElement | nullread-only - The
<table>the element enhances.
Methods
sort(column: string, direction?: 'ascending' | 'descending'): void- Sorts by the column whose data-sortable value, or header text, is column: ascending unless direction says otherwise. It moves aria-sort to that header and, without manual-sort, reorders the rows. Like a value set from script, it fires no
aihio-sortand announces nothing.
Events
aihio-sort-
detail
{ column: string, direction: 'ascending' | 'descending' } - Fired when the person sorts by a column, once its header carries the new aria-sort and, without manual-sort, the rows are in their new order. column is the header's data-sortable value, or its text when that is empty. Not fired by sort().
Native elements
aihio-table enhances these elements rather than replacing them, and reads these attributes
on them. The linter checks them like its own.
<th>
A header cell. In <thead> it names a column; in a body row, with scope="row", it names the row.
Attributes
data-sortable- string
- Makes the column sortable: the header's content becomes a button that sorts the rows by it, ascending first and then descending. The value names the column for
aihio-sortand sort(), and can be left empty when nothing reads it. Only on a<th>in<thead>. aria-sort-
one of
ascendingdescendingnoneother - The order the rows are in. Write it on the header the rows are sorted by when the page loads; without manual-sort the table puts the rows in that order. The table moves it from header to header as people sort.
data-numeric-
boolean
default
false - Lines a column of figures up by place value: end-aligned, in tabular digits. Put it on the column's header and on each of its cells.
data-sort-value- string
- What a row header sorts by, when its text does not sort the way it reads. See
<td>.
<td>
A data cell.
Attributes
data-sort-value- string
- What the cell sorts by when its text does not sort the way it reads, such as a priority ("High" before "Low") or a date written out. A
<time datetime>in the cell is read the same way, so a date marked up as one needs nothing more. Figures written for the page's language ("€1,250.50") are read as numbers without it. data-numeric-
boolean
default
false - Lines the figure up with the rest of its column. See
<th>.
Composition
- Required children
-
table - Allowed children
-
table
Accessibility
What you have to provide, and what the component already does. The obligations with a rule are checked by
aihio lint and by the dev build's console warnings.
Your obligations
-
error When the table has no caption, aria-label, or aria-labelledbyName the table: a
<caption>as its first child, aria-labelledby pointing at the visible heading above it, or aria-label. An unnamed table is announced only as "table", and so is the scroll box it gets on a narrow screen.Checked as
table-accessible-name -
error When the table has no<th>cellsName each column with a
<th>in a<thead>row, and each row with<th scope="row">where one cell identifies it. Bold text in an ordinary cell is not a header.Checked as
table-header-cells -
error When a row, header, or cell responds to clicks and holds no link or buttonSort with data-sortable on the
<th>, and put a link or button in the row for anything else, such as a link in the cell that names it. A click handler on a row or cell is reachable by pointer only.Checked as
table-click-handler -
warn When manual-sort is set and a sortable header has no column nameGive each sortable header a name,
data-sortable="amount", soaihio-sortsays which column to sort by without depending on the header's wording.Checked as
table-sort-column-name
Handled for you
- It stays a native
<table>: rows, columns, and header cells keep the platform's table semantics, so screen reader table navigation names the column and row of every cell, and nothing is re-created with ARIA roles - A sortable header's content moves into a
<button>inside the<th>, which carries aria-sort, so the header keeps its role and the button sorts; the new order is announced in a polite status region - The first activation sorts ascending and the next descending; rows with no value go last either way, ties keep their order, a column of figures compares as numbers (read the way the page's language writes them), and text compares in the page's language with digits in numeric order ("Item 2" before "Item 10")
- Rows are moved with moveBefore() where the browser has it, so focus and state inside a row survive a sort; elsewhere focus is put back after the move
- A table wider than its box (or, with sticky-header, taller) scrolls inside it, and the box becomes a tab stop and a region named by the caption, so the keyboard can scroll it and a screen reader says what it is; when it fits again it is neither
- The sort button's focus ring is drawn inside the header cell, so the scroll box never clips it
- loading sets aria-busy on the table while the rows are stale
- Sorting is by a button, not a click handler on the cell, and the sort indicator changes shape, not only colour; under forced colours the sort buttons are drawn as system buttons
In patterns
Canonical compositions that use aihio-table.