43Table
Hairlines only. Numeric columns are tabular and end-aligned so digits stack; the wrapper is mandatory, because it is what makes a wide table scroll inside itself on a phone. Everything past the base is an opt-in modifier — four header treatments, three densities, row state, cell tone, data bars and a heat ramp — and state is always an attribute a server can print from a value, never a class a template has to map.
Use it for
- Lesson lists, scores, gradebooks, storage reports — anything genuinely tabular
- Admin lists, where
--compactplus row actions is the working shape - A matrix (activity by week, coverage by module) with
--matrixand the heat ramp
Not for
- Card-shaped content forced into rows
- A layout — the wrapper and a grid do that job without lying to a screen reader
- A chart. A data bar in a cell annotates a number; a cell with only a bar in it is a chart that lost its axis
Header treatments
The default — mono, uppercase, label ink — is right for almost every table in the product. --head-filled is for a header that scrolls (it re-announces itself as chrome), --head-strong brackets a table that has a tfoot total, --head-plain is for a table inside an article, where mono uppercase reads as product chrome dropped into the writing, and --head-brand is the report header — at most one per screen, because it is a filled band and two of them compete.
| Filled | Records |
|---|---|
| Account | 12,480 |
| Strong | Records |
|---|---|
| Account | 12,480 |
| Plain | Records |
|---|---|
| Account | 12,480 |
| Brand | Records |
|---|---|
| Account | 12,480 |
markup
<div class="ns-table-wrap"><table class="ns-table ns-table--compact ns-table--head-filled">
<thead><tr><th scope="col">Filled</th><th scope="col" class="ns-table__num">Records</th></tr></thead>
<tbody><tr><td>Account</td><td class="ns-table__num">12,480</td></tr></tbody>
</table></div>
<div class="ns-table-wrap"><table class="ns-table ns-table--compact ns-table--head-strong">
<thead><tr><th scope="col">Strong</th><th scope="col" class="ns-table__num">Records</th></tr></thead>
<tbody><tr><td>Account</td><td class="ns-table__num">12,480</td></tr></tbody>
</table></div>
<div class="ns-table-wrap"><table class="ns-table ns-table--compact ns-table--head-plain">
<thead><tr><th scope="col">Plain</th><th scope="col" class="ns-table__num">Records</th></tr></thead>
<tbody><tr><td>Account</td><td class="ns-table__num">12,480</td></tr></tbody>
</table></div>
<div class="ns-table-wrap"><table class="ns-table ns-table--compact ns-table--head-brand">
<thead><tr><th scope="col">Brand</th><th scope="col" class="ns-table__num">Records</th></tr></thead>
<tbody><tr><td>Account</td><td class="ns-table__num">12,480</td></tr></tbody>
</table></div>Density and rules
--compact for an admin list the reader is scanning for one row; --roomy for a short table that is the main content of the screen. --bordered adds the vertical hairline, for a grid where the column is a real unit; --open drops the horizontal one, for a table short enough that the header rule alone holds it.
| Object | Records | Storage |
|---|---|---|
| Account | 12,480 | 24 MB |
| Contact | 38,102 | 71 MB |
| Object | Records | Storage |
|---|---|---|
| Account | 12,480 | 24 MB |
| Contact | 38,102 | 71 MB |
markup
<div class="ns-table-wrap"><table class="ns-table ns-table--compact ns-table--bordered">
<thead><tr><th scope="col">Object</th><th scope="col" class="ns-table__num">Records</th><th scope="col" class="ns-table__num">Storage</th></tr></thead>
<tbody>
<tr><td>Account</td><td class="ns-table__num">12,480</td><td class="ns-table__num">24 MB</td></tr>
<tr><td>Contact</td><td class="ns-table__num">38,102</td><td class="ns-table__num">71 MB</td></tr>
</tbody>
</table></div>
<div class="ns-table-wrap"><table class="ns-table ns-table--roomy ns-table--open">
<thead><tr><th scope="col">Object</th><th scope="col" class="ns-table__num">Records</th><th scope="col" class="ns-table__num">Storage</th></tr></thead>
<tbody>
<tr><td>Account</td><td class="ns-table__num">12,480</td><td class="ns-table__num">24 MB</td></tr>
<tr><td>Contact</td><td class="ns-table__num">38,102</td><td class="ns-table__num">71 MB</td></tr>
</tbody>
</table></div>Row state
data-state on the <tr> — an attribute a server prints from a value, so Handlebars and React produce the same markup without either owning a mapping. Every state is a 3px leading edge plus a wash, so it survives grayscale and forced-colors. Use it when the whole row is the story; use cell tone below when one value is.
| Learner | Status | Score |
|---|---|---|
| Priya Nair | In progress | — |
| Arun Menon | Passed | 94 |
| Kavya Rao | At risk | 61 |
| Dev Sharma | Failed | 38 |
| Meera Iyer | Withdrawn | — |
markup
<div class="ns-table-wrap"><table class="ns-table">
<thead><tr><th scope="col">Learner</th><th scope="col">Status</th><th scope="col" class="ns-table__num">Score</th></tr></thead>
<tbody>
<tr data-state="current"><td class="ns-table__strong">Priya Nair</td><td><span class="ns-status ns-status--info">In progress</span></td><td class="ns-table__num">—</td></tr>
<tr data-state="success"><td>Arun Menon</td><td><span class="ns-status ns-status--success">Passed</span></td><td class="ns-table__num">94</td></tr>
<tr data-state="warning"><td>Kavya Rao</td><td><span class="ns-status ns-status--warning">At risk</span></td><td class="ns-table__num">61</td></tr>
<tr data-state="danger"><td>Dev Sharma</td><td><span class="ns-status ns-status--error">Failed</span></td><td class="ns-table__num">38</td></tr>
<tr data-state="muted"><td>Meera Iyer</td><td><span class="ns-status ns-status--idle">Withdrawn</span></td><td class="ns-table__num">—</td></tr>
</tbody>
</table></div>Cell tone and delta
data-tone tints one cell when one value in an otherwise fine row is the story. The wash is deliberately weak and the ink carries the signal — a saturated fill behind body text fails contrast on the first long value. .ns-table__delta is a caret and a sign and a colour; data-polarity="inverse" flips it for a metric where up is bad.
| Module | Completion | Drop-off |
|---|---|---|
| Objects & fields | 96% pass | 2.1% |
| SOQL basics | 64% pass | 8.4% |
| Apex triggers | 31% pass | 14.9% |
| Flow builder | not run | 0.0% |
markup
<div class="ns-table-wrap"><table class="ns-table">
<thead><tr><th scope="col">Module</th><th scope="col" class="ns-table__num">Completion</th><th scope="col" class="ns-table__num">Drop-off</th></tr></thead>
<tbody>
<tr><td>Objects & fields</td><td class="ns-table__num" data-tone="success">96% pass</td><td class="ns-table__num"><span class="ns-table__delta" data-dir="down" data-polarity="inverse">2.1%</span></td></tr>
<tr><td>SOQL basics</td><td class="ns-table__num" data-tone="warning">64% pass</td><td class="ns-table__num"><span class="ns-table__delta" data-dir="up" data-polarity="inverse">8.4%</span></td></tr>
<tr><td>Apex triggers</td><td class="ns-table__num" data-tone="danger">31% pass</td><td class="ns-table__num"><span class="ns-table__delta" data-dir="up" data-polarity="inverse">14.9%</span></td></tr>
<tr><td>Flow builder</td><td class="ns-table__num" data-tone="neutral">not run</td><td class="ns-table__num"><span class="ns-table__delta">0.0%</span></td></tr>
</tbody>
</table></div>Data bars
Magnitude encoded in length, which reads pre-attentively and survives every colour condition there is. The percentage arrives as a custom property — style="--ns-bar:62%" — which is the one inline style this system allows, because it is a datum rather than a style choice. The number stays printed in the cell.
| Module | Cohort completion |
|---|---|
| Objects & fields | |
| SOQL basics | |
| Apex triggers | |
| Integration |
markup
<div class="ns-table-wrap"><table class="ns-table ns-table--compact">
<thead><tr><th scope="col">Module</th><th scope="col">Cohort completion</th></tr></thead>
<tbody>
<tr><td>Objects & fields</td><td><span class="ns-table__bar" style="--ns-bar:96%" data-tone="success">96%</span></td></tr>
<tr><td>SOQL basics</td><td><span class="ns-table__bar" style="--ns-bar:64%">64%</span></td></tr>
<tr><td>Apex triggers</td><td><span class="ns-table__bar" style="--ns-bar:31%" data-tone="warning">31%</span></td></tr>
<tr><td>Integration</td><td><span class="ns-table__bar" style="--ns-bar:8%" data-tone="danger">8%</span></td></tr>
</tbody>
</table></div>Heat matrix
data-heat="0"–"4" against the sequential dataviz ramp — four steps, because that is the honest resolution of the ramp. The wash is capped low enough that the cell's own value stays readable on both themes, which is the point: heat is an accent on a printed number, never a replacement for it. A grid of empty coloured squares is unreadable to anyone who cannot see the hue and unprintable for everyone.
| Module | W1 | W2 | W3 | W4 | W5 |
|---|---|---|---|---|---|
| Objects | 41 | 28 | 12 | 4 | 0 |
| SOQL | 6 | 39 | 22 | 11 | 3 |
| Apex | 0 | 5 | 26 | 44 | 14 |
markup
<div class="ns-table-wrap"><table class="ns-table ns-table--matrix ns-table--bordered ns-table--stickycol">
<thead><tr><th scope="col">Module</th><th scope="col">W1</th><th scope="col">W2</th><th scope="col">W3</th><th scope="col">W4</th><th scope="col">W5</th></tr></thead>
<tbody>
<tr><th scope="row">Objects</th><td data-heat="4">41</td><td data-heat="3">28</td><td data-heat="2">12</td><td data-heat="1">4</td><td data-heat="0">0</td></tr>
<tr><th scope="row">SOQL</th><td data-heat="1">6</td><td data-heat="4">39</td><td data-heat="3">22</td><td data-heat="2">11</td><td data-heat="1">3</td></tr>
<tr><th scope="row">Apex</th><td data-heat="0">0</td><td data-heat="1">5</td><td data-heat="3">26</td><td data-heat="4">44</td><td data-heat="2">14</td></tr>
</tbody>
</table></div>Sortable and sticky
The sort control is a real <button> inside the <th>, so it is keyboard reachable; the <th> carries aria-sort, which is what gets announced. --sticky pins the header to the wrapper's scroll — sorting is a property of the column, and a reader three hundred rows down has no header on screen to remind them which one it was.
| Lesson | Duration | |
|---|---|---|
| Your first org | 12:04 | 100 |
| Objects & fields | 18:30 | 92 |
| SOQL basics | 21:15 | 78 |
| Apex triggers | 26:40 | 64 |
| Flow builder | 19:02 | 55 |
| Integration | 31:18 | 41 |
markup
<div class="ns-table-wrap" tabindex="0" style="max-block-size:12rem"><table class="ns-table ns-table--compact ns-table--sticky ns-table--head-filled">
<thead><tr>
<th scope="col">Lesson</th>
<th scope="col">Duration</th>
<th scope="col" class="ns-table__num" aria-sort="descending"><button type="button" class="ns-table__sort">Score</button></th>
</tr></thead>
<tbody>
<tr><td>Your first org</td><td>12:04</td><td class="ns-table__num">100</td></tr>
<tr><td>Objects & fields</td><td>18:30</td><td class="ns-table__num">92</td></tr>
<tr><td>SOQL basics</td><td>21:15</td><td class="ns-table__num">78</td></tr>
<tr><td>Apex triggers</td><td>26:40</td><td class="ns-table__num">64</td></tr>
<tr><td>Flow builder</td><td>19:02</td><td class="ns-table__num">55</td></tr>
<tr><td>Integration</td><td>31:18</td><td class="ns-table__num">41</td></tr>
</tbody>
</table></div>The full data table
Toolbar, selection column, row actions, totals and a footer count — all inside the wrapper, so the box frames the whole apparatus as one object instead of leaving three loose elements stacked on the page. The actions reveal on hover and focus-within, at a reserved width so revealing them does not reflow the table.
| Learner | Track | Progress | Actions | |
|---|---|---|---|---|
| Priya Nairpriya@example.com | ADMIN-201 | 82% | ||
| Arun Menonarun@example.com | DEV-101 | 100% | ||
| Kavya Raokavya@example.com | DEV-101 | 47% | ||
| 3 learners | 76% | |||
markup
<div class="ns-table-wrap">
<div class="ns-table__toolbar">
<span class="ns-table__toolbar-title">Enrolments</span>
<span class="ns-table__count">2 selected</span>
<span class="ns-table__toolbar-end">
<button class="ns-btn ns-btn--outline ns-btn--xs">Export</button>
<button class="ns-btn ns-btn--primary ns-btn--xs">Invite</button>
</span>
</div>
<table class="ns-table ns-table--compact">
<thead><tr>
<th scope="col" class="ns-table__select"><input type="checkbox" class="ns-checkbox" aria-label="Select all rows"></th>
<th scope="col">Learner</th>
<th scope="col">Track</th>
<th scope="col" class="ns-table__num">Progress</th>
<th scope="col"><span class="ns-visually-hidden">Actions</span></th>
</tr></thead>
<tbody>
<tr aria-selected="true">
<td class="ns-table__select"><input type="checkbox" class="ns-checkbox" checked aria-label="Select Priya Nair"></td>
<td><span class="ns-table__stack"><span class="ns-table__strong">Priya Nair</span><span class="ns-table__sub">priya@example.com</span></span></td>
<td class="ns-table__code">ADMIN-201</td>
<td class="ns-table__num">82%</td>
<td class="ns-table__actions"><button class="ns-btn ns-btn--quiet ns-btn--icon ns-btn--xs" aria-label="Edit Priya Nair"><i class="ph ph-pen-nib" aria-hidden="true"></i></button></td>
</tr>
<tr aria-selected="true">
<td class="ns-table__select"><input type="checkbox" class="ns-checkbox" checked aria-label="Select Arun Menon"></td>
<td><span class="ns-table__stack"><span class="ns-table__strong">Arun Menon</span><span class="ns-table__sub">arun@example.com</span></span></td>
<td class="ns-table__code">DEV-101</td>
<td class="ns-table__num">100%</td>
<td class="ns-table__actions"><button class="ns-btn ns-btn--quiet ns-btn--icon ns-btn--xs" aria-label="Edit Arun Menon"><i class="ph ph-pen-nib" aria-hidden="true"></i></button></td>
</tr>
<tr>
<td class="ns-table__select"><input type="checkbox" class="ns-checkbox" aria-label="Select Kavya Rao"></td>
<td><span class="ns-table__stack"><span class="ns-table__strong">Kavya Rao</span><span class="ns-table__sub">kavya@example.com</span></span></td>
<td class="ns-table__code">DEV-101</td>
<td class="ns-table__num">47%</td>
<td class="ns-table__actions"><button class="ns-btn ns-btn--quiet ns-btn--icon ns-btn--xs" aria-label="Edit Kavya Rao"><i class="ph ph-pen-nib" aria-hidden="true"></i></button></td>
</tr>
</tbody>
<tfoot><tr><td colspan="3">3 learners</td><td class="ns-table__num">76%</td><td></td></tr></tfoot>
</table>
<div class="ns-table__footer">
<span class="ns-table__count">1–3 of 128</span>
<span class="ns-table__toolbar-end">
<button class="ns-btn ns-btn--outline ns-btn--xs" disabled>Previous</button>
<button class="ns-btn ns-btn--outline ns-btn--xs">Next</button>
</span>
</div>
</div>Rows as links
The clickable thing stays a real <a> in the first cell, stretched over its row — a row-level click handler is invisible to the keyboard and to a screen reader, and cannot be opened in a new tab. The caret in the last cell appears on hover and on focus-within, so the affordance reaches both.
| Course | Lessons | Enrolled |
|---|---|---|
| Salesforce Admin 201 | 34 | 1,204 |
| Apex Development 101 | 28 | 862 |
markup
<div class="ns-table-wrap"><table class="ns-table ns-table--rowlink">
<thead><tr><th scope="col">Course</th><th scope="col">Lessons</th><th scope="col" class="ns-table__num">Enrolled</th></tr></thead>
<tbody>
<tr><td><a class="ns-table__link ns-table__link--stretch" href="#">Salesforce Admin 201</a></td><td>34</td><td class="ns-table__num">1,204</td></tr>
<tr><td><a class="ns-table__link ns-table__link--stretch" href="#">Apex Development 101</a></td><td>28</td><td class="ns-table__num">862</td></tr>
</tbody>
</table></div>Empty
An empty table keeps its header — the columns are information about what would be here, and a table that collapses to a bare sentence has thrown that away.
| Learner | Track | Progress |
|---|---|---|
| No enrolments yet Invite your first learner and they will appear here. | ||
markup
<div class="ns-table-wrap"><table class="ns-table">
<thead><tr><th scope="col">Learner</th><th scope="col">Track</th><th scope="col" class="ns-table__num">Progress</th></tr></thead>
<tbody><tr><td class="ns-table__empty" colspan="3">
<span class="ns-table__empty-title">No enrolments yet</span>
Invite your first learner and they will appear here.
</td></tr></tbody>
</table></div>Stacked on small screens
The default answer for a wide table on a phone is the scrolling wrapper. This is the other one: below 48rem each row becomes a small card and each cell prints its own column label from data-label. Right when a row is an entity the reader acts on; wrong when the table is a grid of figures compared down the column, because stacking destroys the column, which was the comparison. Narrow the window to see it.
| Invoice | Date | Status | Amount |
|---|---|---|---|
| INV-20841 | 12 Aug 2026 | Paid | ₹4,999 |
| INV-20842 | 19 Aug 2026 | Due | ₹9,499 |
markup
<div class="ns-table-wrap"><table class="ns-table ns-table--stacked">
<thead><tr><th scope="col">Invoice</th><th scope="col">Date</th><th scope="col">Status</th><th scope="col" class="ns-table__num">Amount</th></tr></thead>
<tbody>
<tr><td data-label="Invoice" class="ns-table__code">INV-20841</td><td data-label="Date">12 Aug 2026</td><td data-label="Status"><span class="ns-status ns-status--success">Paid</span></td><td data-label="Amount" class="ns-table__num">₹4,999</td></tr>
<tr data-state="warning"><td data-label="Invoice" class="ns-table__code">INV-20842</td><td data-label="Date">19 Aug 2026</td><td data-label="Status"><span class="ns-status ns-status--warning">Due</span></td><td data-label="Amount" class="ns-table__num">₹9,499</td></tr>
</tbody>
</table></div>Zebra — opt-in only
The base does not stripe: striping is a second structuring device competing with the hairline, and with mono numerals the rows already read as rows. --zebra exists for the one case that argument does not cover — a wide table that scrolls sideways, where the eye travels a long way from the row label to the cell. If the table fits on screen without scrolling, the answer is no. Scroll this one to see why.
| Learner | W1 | W2 | W3 | W4 | W5 | W6 | W7 | Total |
|---|---|---|---|---|---|---|---|---|
| Priya Nair | 8 | 9 | 7 | 10 | 9 | 8 | 10 | 61 |
| Arun Menon | 10 | 10 | 9 | 9 | 10 | 10 | 9 | 67 |
| Kavya Rao | 6 | 5 | 7 | 4 | 6 | 5 | 7 | 40 |
| Dev Sharma | 4 | 6 | 3 | 5 | 4 | 6 | 5 | 33 |
markup
<div class="ns-table-wrap" tabindex="0"><table class="ns-table ns-table--compact ns-table--zebra ns-table--stickycol" style="min-inline-size:52rem">
<thead><tr><th scope="col">Learner</th><th scope="col">W1</th><th scope="col">W2</th><th scope="col">W3</th><th scope="col">W4</th><th scope="col">W5</th><th scope="col">W6</th><th scope="col">W7</th><th scope="col" class="ns-table__num">Total</th></tr></thead>
<tbody>
<tr><th scope="row">Priya Nair</th><td>8</td><td>9</td><td>7</td><td>10</td><td>9</td><td>8</td><td>10</td><td class="ns-table__num">61</td></tr>
<tr><th scope="row">Arun Menon</th><td>10</td><td>10</td><td>9</td><td>9</td><td>10</td><td>10</td><td>9</td><td class="ns-table__num">67</td></tr>
<tr><th scope="row">Kavya Rao</th><td>6</td><td>5</td><td>7</td><td>4</td><td>6</td><td>5</td><td>7</td><td class="ns-table__num">40</td></tr>
<tr><th scope="row">Dev Sharma</th><td>4</td><td>6</td><td>3</td><td>5</td><td>4</td><td>6</td><td>5</td><td class="ns-table__num">33</td></tr>
</tbody>
</table></div>Key / value
A spec table whose first column is the label. Related to Definition list — use this when the rest of the page is already tabular.
| API version | v62.0 |
|---|---|
| Edition | Enterprise |
| Org ID | 00D5j000000abcAAA |
markup
<div class="ns-table-wrap"><table class="ns-table ns-table--keyvalue">
<tbody>
<tr><th scope="row">API version</th><td>v62.0</td></tr>
<tr><th scope="row">Edition</th><td>Enterprise</td></tr>
<tr><th scope="row">Org ID</th><td>00D5j000000abcAAA</td></tr>
</tbody>
</table></div>Default
| Lesson | Duration | Score |
|---|---|---|
| Your first org | 12:04 | 100 |
| Objects & fields | 18:30 | 92 |
| SOQL basics | 21:15 | 78 |
markup
<div class="ns-table-wrap" tabindex="0" style="max-inline-size:28rem">
<table class="ns-table">
<thead><tr><th scope="col">Lesson</th><th scope="col">Duration</th><th scope="col" class="ns-table__num">Score</th></tr></thead>
<tbody>
<tr><td>Your first org</td><td>12:04</td><td class="ns-table__num">100</td></tr>
<tr><td>Objects & fields</td><td>18:30</td><td class="ns-table__num">92</td></tr>
<tr><td>SOQL basics</td><td>21:15</td><td class="ns-table__num">78</td></tr>
</tbody>
</table>
</div>Accessibility contract
- tabindex="0" on the wrapper — a scroll region must be keyboard-reachable
- Sortable headers put aria-sort on the <th> and a real <button> inside it
- Every tone, state and heat level is a wash PLUS an edge, a glyph or a printed value — colour is never the only signal, and every wash is dropped under forced-colors and in print
- Row actions reveal on :hover AND :focus-within; on touch they are simply always visible
- --stacked hides the header visually but keeps it in the DOM, so the table is still a table to a screen reader