Data table

Data tables should support scanning, sorting, filtering, and repeated comparison. Keep column labels stable, expose sort direction, and avoid hiding essential data behind decorative layouts.

Anchor index15
Breadcrumbs10
Button20

When to use#

  • A table supports scanning, sorting, filtering, and repeated comparison; keep column labels stable, expose the sort direction, and avoid hiding essential data behind decorative layouts
  • Use the shared table wrapper whenever a table needs interactive sorting
  • When a table-like page exposes active filters, use the shared filter row partial rather than metadata tag classes — filter chips change visible rows; metadata tags only describe content
  • Keep column widths consistent across repeated tables — prefer explicit width utilities on matching header and body cells, and use fixed: true when several tables on the same page should align visually

Implementation#

{{ partial "data-page/table-wrap.html" (dict "minWidth" "56rem" "sortable" true "fixed" true) }}
    <thead>…</thead>
    <tbody>…</tbody>
{{ partial "data-page/table-wrap.html" (dict "mode" "close") }}
<div class="overflow-x-auto border-y border-line" style="--data-table-min-width: 56rem;" tabindex="0" role="region" aria-label="Scrollable table">
  <table class="w-full min-w-[var(--data-table-min-width)] table-fixed border-collapse text-left text-sm" data-sortable-table>
    <thead>
      <tr class="border-b border-line">
        <th scope="col" aria-sort="ascending">
          <button class="table-sort-button" type="button" data-table-sort="name">Name</button>
        </th>
      </tr>
    </thead>
    <tbody>
      <tr data-table-row>
        <td data-sort-value="2026-08-11">2026.08.11</td>
      </tr>
    </tbody>
  </table>
</div>

Sorting is wired by initTableLists in assets/js/site.js. Its contract:

  • Wrap the table area in an element with data-table-list; the JavaScript looks for one sortable table (data-sortable-table) inside that wrapper
  • Column headers use table-sort-button and a stable data-table-sort key; the initially sorted <th> must set aria-sort to ascending or descending, and inactive column headers omit it or use the value managed by JavaScript
  • Rows must use data-table-row; each sortable cell should provide data-sort-value, especially when the visible text includes links, badges, dates, counts, or localized labels

The Hugo partial is layouts/partials/data-page/table-wrap.html, called once to open the scroll wrapper and <table> and once with mode: "close" to close them. The wrapper itself carries tabindex="0", role="region", and the dataTableScrollRegion i18n label, so keyboard users can reach and scroll it independent of the table’s own content; <th> scope is set by the calling page, not the partial, since only the page authoring the row knows whether a header names a column (scope="col", the common case) or a row (scope="row", used where the first cell in a data row identifies that row, as on the Starfleet registry table). Sorting behaviour lives in assets/js/site.js (initTableLists); sort affordances and direction symbols live in assets/css/site.css. The talks archive predates this contract and runs a parallel behaviour (initTalksArchive with data-talks-* attributes) on the same visual classes.

Options#

OptionPurpose
minWidthMinimum table width before the wrapper scrolls horizontally (default 48rem)
sortableAdds data-sortable-table so the shared sorting behaviour can attach
fixedAdds table-fixed so repeated tables on one page align visually
captionScreen-reader-only <caption> for the table
classExtra classes on the scroll wrapper
tableClassExtra classes on the <table>
tableAttrsExtra attributes on the <table>
modeopen (default) or close — the partial is called once on each side of the body

Interface manifest

Kind
Component
Category
Data
Status
Implemented
Implementation
Partial:layouts/partials/data-page/table-wrap.htmlLayout:layouts/_default/_markup/render-table.htmlCSS:assets/css/site.cssJavaScript:assets/js/site.jsi18n:dataTableScrollRegion

Accessibility#

  • The component is a real <table> with <thead> and <tbody> inside a scrolling <div>; pass caption so screen readers get a name for the table — the partial renders it as a visually hidden <caption>
  • Every <th> carries scope="col" or scope="row", set by the calling page, so screen readers announce which column or row a cell belongs to instead of relying on the implicit position of <th> in <thead>
  • The scroll wrapper carries tabindex="0", role="region", and the dataTableScrollRegion i18n label, so keyboard-only users can reach and scroll wide tables even when no cell inside is focusable
  • Sort headers are native <button type="button"> elements inside <th> cells: Tab reaches each one and Enter or Space re-sorts, with no custom key handling
  • initTableLists keeps aria-sort current — ascending, descending, or none — on each <th> column header, where ARIA defines the attribute; it does not put aria-sort on the nested button
  • Avoid using a table for layout — <table> announces row and column structure to screen readers; decorative grids belong in CSS