Build an Accessible Table
Are you in the right place? If you are constrained to a third-party component library, see the Table Accessibility Checklist and ensure that it passes. Otherwise, follow the guidance here to choose the best option for your project.
Use the NYSDS Table Web Component
The NYSDS Table is a web component you can drop into your project and use right away. For most teams, this is the simplest way to achieve an accessible component. This means teams don’t need to manually implement accessibility or test for it.
What's already handled for you:-
Keyboard Navigation
Tab, Enter, Space, and Arrows work as intended
-
Screen reader support
Tested with NVDA, JAWS, and VoiceOver
-
Focus management
Focus stays where users expect it
-
Voice control
Supports Windows and macOS
-
Zoom magnification
Works correctly at 200% browser zoom
-
WCAG 2.2 AA conformant
Meets New York State standards
Alternatives
For projects not ready to adopt the Design System Whether you seek to remediate an existing component or build one from scratch, these reference implementations will ensure an accessible outcome. For more details, read about this pattern.
Style and enhance the native HTML table element
When to use
If your team has source-level control over UI code,
wants to avoid error-prone re-creation of semantics,
and would like to leverage a powerful native API,
then the table element
is a great option for careful progressive enhancement
and first-class accessibility.
| Example | |||
|---|---|---|---|
| Copyright sign | © |
34% | © |
| Registered trade mark sign | ® |
6% | ® |
| Trade mark sign | ™ |
100% | ™ |
<table data-component="table" class="nysa11y table" data-html="native">
<caption data-part="tableCaption">
<span class="table__caption">HTML named entities</span>
<span class="sr-only"> Column headers with buttons are sortable.</span>
</caption>
<thead data-part="tableHead">
<tr>
<!-- All headers have appropriate scope (col|row) -->
<!-- Initial sort setting is dependent on attribute presence and value -->
<th scope="col" aria-sort="ascending">
<button type="button" class="column__sorter">
<div>Symbol name</div>
<span aria-hidden="true"></span>
</button>
</th>
<th scope="col">
<button type="button" class="column__sorter">
<div>HTML entity code</div>
<span aria-hidden="true"></span>
</button>
</th>
<th scope="col" data-align="end">
<button type="button" class="column__sorter">
<div>Browser support</div>
<span aria-hidden="true"></span>
</button>
</th>
<th scope="col" data-align="center">Example</th>
</tr>
</thead>
<tbody data-part="tableBody">
<tr>
<td>Copyright sign</td>
<td><code>&copy;</code></td>
<td>34%</td>
<td class="demo-entity-example">©</td>
</tr>
<tr>
<td>Registered trade mark sign</td>
<td><code>&reg;</code></td>
<td>6%</td>
<td class="demo-entity-example">®</td>
</tr>
<tr>
<td>Trade mark sign</td>
<td><code>&trade;</code></td>
<td>100%</td>
<td class="demo-entity-example">™</td>
</tr>
</tbody>
</table>
/**
* NYSA11y Table (native)
* Path: /assets/nysa11y/table-native.css
* Depends on /assets/nysa11y/table-native.js
*/
@layer nysa11y {
body:has(.nysa11y) {
font-size: 16px; /* 1.0rem */
table.nysa11y.table[data-component="table"][data-html="native"] {
*,
*:before,
*:after {
box-sizing: border-box;
}
background-color: var(--nys-color-ink-reverse, #ffffff);
border-collapse: collapse;
font-family: var(
--nys-font-family-sans,
"Proxima Nova",
-apple-system,
"BlinkMacSystemFont",
"Segoe UI",
"Roboto",
"Helvetica",
"Arial",
sans-serif,
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol"
);
& [data-part="tableCaption"] {
padding-inline: 12px;
padding-block: 20px;
font-size: 1.125rem;
font-weight: 600;
text-align: start;
&.caption-bottom,
.caption-bottom & {
caption-side: bottom;
}
}
& [data-part="tableHead"] {
/* non-button text */
& th {
font-weight: 700;
font-size: 0.91rem;
text-align: start;
padding-inline: 12px;
&:not([aria-sort]) button span::after {
content: "\2195";
}
&[aria-sort="descending"] button span::after {
content: "\2193";
}
&[aria-sort="ascending"] button span::after {
content: "\2191";
}
&:has(button) {
padding: 0;
}
/* button text */
& button.column__sorter {
align-items: center;
background: transparent;
border: none;
display: flex;
flex-wrap: nowrap;
font-size: 0.85rem;
font-weight: 600;
gap: 0.75rem;
justify-content: space-between;
line-height: 1;
min-height: 3rem;
padding-block: 0;
padding-inline: 12px;
text-align: start;
width: 100%;
&:hover {
background-color: var(--nys-color-black-transparent-100, #0000001a);
}
&:focus-visible {
outline: solid var(--nys-border-width-md, 2px) var(--nys-color-focus, #004dd1);
}
& span[aria-hidden] {
flex-shrink: 1;
font-size: 1.25rem;
text-align: center;
}
}
&[aria-sort] button.column__sorter {
background-color: var(--nys-color-blue-100, #cce0f6);
&:hover {
background-color: var(--nys-color-blue-200, #a3c2ee);
}
}
}
}
& [data-part="tableBody"] {
td {
padding-block: 12px;
padding-inline: 12px;
vertical-align: top;
&.align-end {
text-align: end;
}
&.align-center {
text-align: center;
}
&[data-state="sorted"] {
background-color: var(--nys-color-blue-10, #f2f7fc);
}
}
th[scope="row"] {
text-align: start;
}
}
}
}
}
/**
* NYSA11y Table (native)
* Path: /assets/nysa11y/table-native.js
* Depends on /assets/nysa11y/table-native.css
*/
const nysa11y = window.nysa11y || {};
class Table {
#selectorRoot = '[data-component="table"].nysa11y';
#selectorHeader = '[data-part="tableHead"] th';
#selectorTbody = '[data-part="tableBody"]';
#selectorButton = "button.column__sorter";
constructor(options = {}) {
this.container = options.container || document;
this.init();
}
init() {
const roots = this.container.querySelectorAll(this.#selectorRoot);
if (!roots.length) return;
roots.forEach((rootEl) => {
this.#setupTable(rootEl);
});
}
#setupTable = (rootEl) => {
const headers = rootEl.querySelectorAll(this.#selectorHeader);
headers.forEach((ch, i) => {
const buttonNode = ch.querySelector(this.#selectorButton);
if (buttonNode) {
buttonNode.setAttribute("data-column-index", i);
buttonNode.removeEventListener("click", this.#handleClick);
buttonNode.addEventListener("click", this.#handleClick);
}
});
// Sync ARIA from DOM state at setup
let sortedIndex = -1;
headers.forEach((ch, i) => {
const buttonNode = ch.querySelector(this.#selectorButton);
if (buttonNode && ch.hasAttribute("aria-sort")) {
const value = ch.getAttribute("aria-sort");
if (value !== "ascending" && value !== "descending") {
ch.removeAttribute("aria-sort");
} else {
sortedIndex = i;
}
}
});
if (sortedIndex !== -1) {
this.#updateSortedAttributes(rootEl, sortedIndex);
}
// Set initial alignments based on thead th data-align attributes
this.#updateColumnAlignments(rootEl);
// Watch for dynamic data-align changes/removals
if (rootEl.__nysa11yTableAlignObserver) {
rootEl.__nysa11yTableAlignObserver.disconnect();
}
const alignObserver = new MutationObserver((mutationsList) => {
mutationsList.forEach((mutation) => {
if (mutation.type === "attributes" && mutation.attributeName === "data-align") {
this.#updateColumnAlignments(rootEl);
}
});
});
alignObserver.observe(rootEl, {
attributes: true,
subtree: true,
attributeFilter: ["data-align"],
});
rootEl.__nysa11yTableAlignObserver = alignObserver;
};
#handleClick = (event) => {
const buttonElement = event.currentTarget;
const rootEl = buttonElement.closest(this.#selectorRoot);
if (!rootEl) return;
const columnIndex = buttonElement.getAttribute("data-column-index");
this.#setColumnHeaderSort(rootEl, columnIndex);
};
#isTextNumeric = (text) => {
const cleaned = text
.trim()
.replace(/^[\$\u20AC\u00A3\u00A5]/, "") // remove leading currency symbols
.replace(/,/g, "") // remove commas
.replace(/%$/, "") // remove trailing percent sign
.trim();
return /^[-+]?\d*\.?\d+$/.test(cleaned);
};
#updateSortedAttributes = (rootEl, columnIndex) => {
const parsedIndex = typeof columnIndex === "string" ? parseInt(columnIndex, 10) : columnIndex;
const tbodyNode = rootEl.querySelector(this.#selectorTbody);
if (tbodyNode) {
const rows = Array.from(tbodyNode.children);
rows.forEach((rowNode) => {
const rowCells = rowNode.querySelectorAll("th, td");
rowCells.forEach((cell, i) => {
if (i === parsedIndex) {
cell.setAttribute("data-state", "sorted");
} else {
cell.removeAttribute("data-state");
}
});
});
}
};
#updateColumnAlignments = (rootEl) => {
const headers = rootEl.querySelectorAll(this.#selectorHeader);
const tbodyNode = rootEl.querySelector(this.#selectorTbody);
if (!tbodyNode) return;
const rows = Array.from(tbodyNode.children);
headers.forEach((ch, columnIndex) => {
const align = ch.getAttribute("data-align");
rows.forEach((rowNode) => {
const rowCells = rowNode.querySelectorAll("th, td");
const cell = rowCells[columnIndex];
if (cell) {
// Remove existing alignment classes
cell.classList.remove("align-center", "align-end");
// Add new alignment class based on data-align
if (align === "center") {
cell.classList.add("align-center");
} else if (align === "end") {
cell.classList.add("align-end");
}
}
});
});
};
#setColumnHeaderSort = (rootEl, columnIndex) => {
const parsedIndex = typeof columnIndex === "string" ? parseInt(columnIndex, 10) : columnIndex;
const headers = rootEl.querySelectorAll(this.#selectorHeader);
const tbodyNode = rootEl.querySelector(this.#selectorTbody);
headers.forEach((ch, i) => {
const buttonNode = ch.querySelector(this.#selectorButton);
if (i === parsedIndex) {
const value = ch.getAttribute("aria-sort");
// Auto-detect numeric column if class "num" is not present
let isNumber = ch.classList.contains("num");
if (!isNumber && tbodyNode) {
const rows = Array.from(tbodyNode.children);
const cellValues = rows
.map((rowNode) => {
const rowCells = rowNode.querySelectorAll("th, td");
const dataCell = rowCells[parsedIndex];
return dataCell ? dataCell.textContent.trim() : "";
})
.filter((text) => text !== "" && text !== "N/A");
if (cellValues.length > 0) {
isNumber = cellValues.every(this.#isTextNumeric);
}
}
const direction = value === "descending" ? "ascending" : "descending";
ch.setAttribute("aria-sort", direction);
this.#sortColumn(rootEl, parsedIndex, direction, isNumber);
this.#updateSortedAttributes(rootEl, parsedIndex);
} else {
if (ch.hasAttribute("aria-sort") && buttonNode) {
ch.removeAttribute("aria-sort");
}
}
});
};
#sortColumn = (rootEl, columnIndex, sortValue, isNumber) => {
const tbodyNode = rootEl.querySelector(this.#selectorTbody);
if (!tbodyNode) return;
const rows = Array.from(tbodyNode.children);
const dataCells = rows.map((rowNode, index) => {
const rowCells = rowNode.querySelectorAll("th, td");
const dataCell = rowCells[columnIndex];
const text = dataCell ? dataCell.textContent.trim() : "";
const value = isNumber ? this.#parseNumber(text) : text.toLowerCase();
return {
index,
value,
rowNode,
};
});
dataCells.sort((a, b) => {
if (a.value === b.value) {
return 0;
}
if (sortValue === "ascending") {
if (isNumber) {
return a.value - b.value;
}
return a.value < b.value ? -1 : 1;
} else {
if (isNumber) {
return b.value - a.value;
}
return a.value > b.value ? -1 : 1;
}
});
// Clear and append sorted rows
tbodyNode.textContent = "";
dataCells.forEach((cell) => {
tbodyNode.appendChild(cell.rowNode);
});
};
#parseNumber = (text) => {
// Remove all characters except digits, minus signs, and decimal dots.
const cleaned = text.replace(/[^\d.-]/g, "");
const parsed = parseFloat(cleaned);
return Number.isNaN(parsed) ? 0 : parsed;
};
}
nysa11y.Table = Table;
document.addEventListener("DOMContentLoaded", () => {
new nysa11y.Table();
});
About this pattern
What is a table?
The noun "table" and the adjective "tabular" identify the presentation of two-dimensional information. The most common form is an ordered set of cells, corresponding to individual data, grouped into a grid structure that can be simple or complex. The horizontal dimension of cells is called a "row" and the vertical is called a "column." When a table represents a matrix, a "cell" is understood as the literal intersection of each row and column in its grid.
Unlike a list, a table can incorporate headers for any or all rows and columns, and even spans of both. Row- and column-headers are integral to the information they describe. In code, headers are semantically distinct from other cells, and this relationship can be made even more explicit with optional attributes in cases of complex table structure.
The point of a table is that it is rigid. Information is easily interpreted by making visual associations between row and column headers.
Mozilla Developer Network
HTML table history in a nutshell
Semantic definition of parts was arguably the foremost design goal in the lineage of markup languages that led to HTML.
In IBM's GML (1969), there was already provision for tabular data rendering.
This was inherited by SGML,
which can be considered the mother and superset of HTML and XML, among others.
SGML was originally designed to enable the sharing of machine-readable large-project documents in government, law, and industry.1 With such a requirement, strict formal definitions were essential. At the behest of the United States Department of Defense, SGML includes the CALS Table Model, developed by Harvey W. Bingham.2 Since nearly all the original group of SGML and HTML language developers were research scientists or doing applied science in industry, academia, or government, data interchange standardization remained a core concern. So it was only natural that RFC 1942: HTML Tables (1996), in which Dave Raggett added
table to what became
HTML 3.2, derived in a simplified form from the CALS model.
By the time of HTML 4.01 (1999) the HTML table elements grew to include caption, colgroup, col,
thead, tr, th, tbody, and tfoot, and the attribute scope (among others).
Early warning signs
Unfortunately for actual enforcement of semantics in authoring, implementation of the native HTML table element by browser vendors has from the start been very permissive, and remains so today. The negative implications of this were evident early on. 1999, the year of HTML 4.01, was also notable for the debut of the Web Content Accessibility Guidelines 1.0, in which confusion between structure and presentation was identified as contributing to the misuse of markup tags that hinders accessibility. The table tags had a dedicated section devoted to correct semantic structure that is almost entirely still valid to this day.Semantic structure
Since 1999 the number and quality of screen readers and other assistive technologies has increased, but what has not changed is their profound reliance on HTML semantics. This is the core semantic structure of a table:-
table
- caption
-
thead
-
tr
- th scope
- td
-
tr
-
tbody
-
tr
- th scope
- td
-
tr
-
tfoot
-
tr
- th scope
- td
-
tr
tableandcaption-
Permitted child elements of
tableare onlycaption,colgroup,thead,tbody,tr, andtfoot, in specific order and number. - The
captionchild element may contain nearly all types of content, not only text. -
If a
tablehas anaria-labelstring or is associated to a heading witharia-labelledby, its accessible name will be calculated from one of them. If the same table also has acaption, the accessibility tree interprets the caption to be the table's accessible description. Depending on navigation method, a screen reader may announce both the ARIA text and the caption text. However if a table has only a caption and noaria-labeloraria-labelledby, the caption becomes the table's accessible name. This remains true when the caption is visually hidden but still accessible in the DOM. A valid caption is nearly always announced by screen readers. thead- The
theadchild element may contain onlytrtable row elements. Its semantic purpose is to identify the head of a table with information about the table's columns. tbody- The
tbodychild element may contain onlytrtable row elements. Its semantic purpose is to identify the body of a table's (main) data. tfoot- The
tfootchild element many contain onlytrtable row elements. Its semantic purpose is to identify the foot of a table with information about the table's columns. tr- The
trdescendant element may contain onlythandtdtable cell elements. Its semantic purpose is to identify a row of cells in a table. thandscope=[col|row]- The
thdescendant element defines a cell as the header of a group of table cells. - Setting the
scopeattribute on headers—th scope="col"—is a best practice with simple tables and a necessity with complex tables, since it explicitly reinforces structural relationships between a header and relevant data cells. The two most common scope attributes arerow, which associates the header with all cells an author wishes to be represented as in its row, andcol, which does the same for column. td- The
tddescendant element defines a cell of a table, the generic container for most tabular data.
Form equals function
As a consequence of the ancestral (SGML) concern with data exchange, these markup elements are mirrored in the JavaScript HTMLTableElement DOM API built into every browser engine. This means a table with correct HTML tag semantics is one that can be effectively programmed. And it is this same valid construction that when parsed by assistive technologies allows them to improve a wide range of user experiences.
The table is such an important HTML element that it gets special treatment by many screen readers. NVDA, JAWS, and VoiceOver all provide dedicated key shortcuts for page navigation to tables. Once a table boundary is reached, they identify it as a table, and attempt to announce its name, description, and number of columns and rows. These attempts will succeed if the table is coded correctly. When screen reader users descend into the table, their screen readers seamlessly switch into a special cell-navigation mode. When these users move between columns or rows with headings while browsing data cells, the new heading is announced. Depending on the screen reader used, there are usually more table-specific capabilities, but these are the baseline.
What would happen if the table below was loaded into browser DOM?
- JavaScript could reference it by ID
CALS, and then freely manipulate any of its constituent parts since they are present and validly map to the HTMLTableElement DOM API - A screen reader could navigate to this table and instantly be oriented that its subject is "What came first"
- (And much more)
How might we expect user experience to change when tables are sortable?
Sortable tables
A sortable table is one that can be dynamically rearranged by the user to display its entries in different order. Most often, the data in a single column is toggled to be ascending or descending by alphanumeric order, and all rows reshuffle according. Sorting is a powerful capability intended to help users understand relationships, find patterns in large lists, and make easier comparisons between many similar items.
Not all tables benefit from being sortable, and some may suffer from it or be technically incompatible with sorting. If the original order of display is itself the main meaning of a table, sorting may dilute that meaning. If a table has cells that span multiple rows or columns, sorting may be impossible, or technically convoluted, or actively confuse the data presentation even if technically achieved.
Sorting is not a native feature of the HTML table, meaning its interaction design and appropriate semantics must be carefully considered, implemented, and tested. The goal is to arrive at the simplest composition that provides an optimal baseline user experience for as many user personas as possible.
Basic column sorting
Sorting requires activation of an element to toggle data state; in our case some content of a column header will be activated to sort by that column.
Should we program event handlers directly on th elements? No!—a native HTML button is a far better choice:
it responds to every form of activation; it fires events; it supports states; it can contain a variety of content; and it is amenable to CSS styling.
Our button will fill the entire available space of each th of each sortable column.
For visual users, symbolic reinforcement to make current state explicit is important.
Widely understood glyphs representing unsorted, sorted ascending, and sorted descending are needed.
These symbols will be rendered inside each button, adjacent to text.
Since these symbols are for visual users only, they will be removed from the accessibility tree with aria-hidden or an empty alt attribute,
depending on the source of the glyph.
Also visual, our buttons must adhere to all rules for interactive components:
they must clearly show focus; they must have sufficient color contrast with their surroundings;
they must have sufficient color contrast with interior text and between interaction states.
We must use CSS to enhance this where the native browsers styles for button fall short, and in particular the sorted state must be unambiguously evident.
Despite all this focus on header buttons, there is one essential job for the th.
The key state semantic to convey for assistive technology is set not on the button but on its containing th.
This makes sense if you consider the button to be just a visual-interactive label of the semantic column header,
which can only be the actual th.
This is done by dynamic injection of aria-sort="[ascending|descending]" as an attribute of the th.
Columns that are not sorted should have this attribute entirely removed.
The baseline desktop screen reader experience thus created is at the very least the sorted column will announce itself as "sorted, ascending" during navigation through table headers.
Even better, NVDA and JAWS reliably announce this as the sort interaction takes place.
macOS VoiceOver emits a beep, upon which a user can invoke the screen reader to read back the column and quickly confirm its sort state.
iOS VoiceOver also emits a beep, but has poor support for dynamically updated aria-sort values.
Not only will it not announce upon update, re-swiping the header may report the old value.
Android TalkBack ignores the aria-sort attribute completely.
Thus users of both iOS VoiceOver and Android TalkBack must swipe down to confirm the following row content has changed.
This lack of affordance is a known issue.
For this reason, the implementation of sort state affordance for all screen readers is open to future revision.
Sort hints for screen readers
If a table is initialized already sorted, for example, by the first column, and a desktop screen reader user navigates by button into the header row, they may quickly infer the purpose of the header buttons from an announcement like: "button, HEADER 1, sorted ascending." This is easily learned and remembered for future use. However it would be ideal to offer more cues to first-time, infrequent, and mobile users. From the benefit of his immense experience, Adrian Roselli advises us there is no one ideal method for this. Our aural UX design decision adopts Roselli's most straightforward option: add a visually-hidden usage hint to the end of the caption text. Though there is a risk that fast-moving or impatient users may move past this hint before hearing it, the advantage is that it will be announced whenever the table is entered, regardless of the navigation means (Arrow, Tab, T, swipe) or the direction of movement (descending or ascending into the table in the DOM). This leaves us with a table that has "HTML named entities" as its visual caption, and "HTML named entities, column headers with buttons are sortable," as its aural caption.