Skip to content
Aihio v2.3.0
Search
GitHub
Search Describe what you are building, or name a component, intent, or token. Components and patterns are ranked the way the MCP server's find tool ranks them for an agent.

<aihio-table> v1.0.0 Markdown for agents

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.

Default density
ServiceRequests
api-gateway12,480
billing-worker3,215
density="default"
Compact density
ServiceRequests
api-gateway12,480
billing-worker3,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.

Invoices
Invoice Customer Status Amount
INV-1042 Northwind Traders Paid €1,250.00
INV-1043 Fabrikam Due €980.50
INV-1044 Contoso Overdue €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.

Deployments
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.

Requests
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.

Customers
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.

Team members
Name Role Actions
Ada Lovelace Owner ⋯ Change role Remove from team
Grace Hopper Admin ⋯ Change role Remove from team
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">&#x22ef;</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">&#x22ef;</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-name error Name 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.

Do

Invoices
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-cells error Name 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.

Do

Invoices
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-handler error Sort 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.

Do

Invoices
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-handler error Sort 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.

Do

Invoices
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-child error missing required child <table>.
  • invalid-child error child <aihio-table-header> is not allowed here. Expected: table.
  • invalid-child error child <aihio-table-body> is not allowed here. Expected: table.
  • unknown-component error unknown Aihio component <aihio-table-header>. Use <thead> instead. Suggests <thead>
  • unknown-component error unknown Aihio component <aihio-table-row>. Use <tr> instead. Suggests <tr>
  • unknown-component error unknown Aihio component <aihio-table-head>. Use <th> instead. Suggests <th>
  • unknown-component error unknown Aihio component <aihio-table-body>. Use <tbody> instead. Suggests <tbody>
  • unknown-component error unknown Aihio component <aihio-table-row>. Use <tr> instead. Suggests <tr>
  • unknown-component error unknown Aihio component <aihio-table-cell>. Use <td> instead. Suggests <td>

Do

Invoices
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-header error <th sortable> does nothing: nothing reads sortable. Write data-sortable on the <th>, and the table makes the header a sort button. Suggests data-sortable
  • table-sortable-header error <th sortable> does nothing: nothing reads sortable. Write data-sortable on the <th>, and the table makes the header a sort button. Suggests data-sortable

Do

Invoices
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 defaultcompact default default
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 | null read-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-sort and 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-sort and 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-labelledby

    Name 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> cells

    Name 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 button

    Sort 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 name

    Give each sortable header a name, data-sortable="amount", so aihio-sort says 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.