NS Design System
v3.0.0 · 257 tokens

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 --compact plus row actions is the working shape
  • A matrix (activity by week, coverage by module) with --matrix and 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.

FilledRecords
Account12,480
StrongRecords
Account12,480
PlainRecords
Account12,480
BrandRecords
Account12,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.

ObjectRecordsStorage
Account12,48024 MB
Contact38,10271 MB
ObjectRecordsStorage
Account12,48024 MB
Contact38,10271 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.

LearnerStatusScore
Priya NairIn progress
Arun MenonPassed94
Kavya RaoAt risk61
Dev SharmaFailed38
Meera IyerWithdrawn
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.

ModuleCompletionDrop-off
Objects & fields96% pass2.1%
SOQL basics64% pass8.4%
Apex triggers31% pass14.9%
Flow buildernot run0.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 &amp; 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.

ModuleCohort completion
Objects & fields96%
SOQL basics64%
Apex triggers31%
Integration8%
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 &amp; 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.

ModuleW1W2W3W4W5
Objects41281240
SOQL63922113
Apex05264414
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 org12:04100
Objects & fields18:3092
SOQL basics21:1578
Apex triggers26:4064
Flow builder19:0255
Integration31:1841
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 &amp; 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.

Enrolments 2 selected
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 learners76%
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.

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.

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

InvoiceDateStatusAmount
INV-2084112 Aug 2026Paid₹4,999
INV-2084219 Aug 2026Due₹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.

LearnerW1W2W3W4W5W6W7Total
Priya Nair89710981061
Arun Menon1010991010967
Kavya Rao657465740
Dev Sharma463546533
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 versionv62.0
EditionEnterprise
Org ID00D5j000000abcAAA
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

LessonDurationScore
Your first org12:04100
Objects & fields18:3092
SOQL basics21:1578
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 &amp; 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