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.


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.

Rely on the required checklists to ensure accessibility is maintained during integration.
HTML named entities  Column headers with buttons are sortable.
Example
Copyright sign © 34% ©
Registered trade mark sign ® 6% ®
Trade mark sign ™ 100%
Copy Code
<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">&nbsp;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>&amp;copy;</code></td>
            <td>34%</td>
            <td class="demo-entity-example">&copy;</td>
        </tr>
        <tr>
            <td>Registered trade mark sign</td>
            <td><code>&amp;reg;</code></td>
            <td>6%</td>
            <td class="demo-entity-example">&reg;</td>
        </tr>
        <tr>
            <td>Trade mark sign</td>
            <td><code>&amp;trade;</code></td>
            <td>100%</td>
            <td class="demo-entity-example">&trade;</td>
        </tr>
    </tbody>
</table>
Copy Code
/**
 * 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;
                }
            }
        }
    }
}
Copy Code
/**
 * 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
    • tbody
      • tr
        • th scope
        • td
    • tfoot
      • tr
        • th scope
        • td
table and caption
Permitted child elements of table are only caption, colgroup, thead, tbody, tr, and tfoot, in specific order and number.
The caption child element may contain nearly all types of content, not only text.
If a table has an aria-label string or is associated to a heading with aria-labelledby, its accessible name will be calculated from one of them. If the same table also has a caption, 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-label or aria-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 thead child element may contain only tr table row elements. Its semantic purpose is to identify the head of a table with information about the table's columns.
tbody
The tbody child element may contain only tr table row elements. Its semantic purpose is to identify the body of a table's (main) data.
tfoot
The tfoot child element many contain only tr table row elements. Its semantic purpose is to identify the foot of a table with information about the table's columns.
tr
The tr descendant element may contain only th and td table cell elements. Its semantic purpose is to identify a row of cells in a table.
th and scope=[col|row]
The th descendant element defines a cell as the header of a group of table cells.
Setting the scope attribute 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 are row, which associates the header with all cells an author wishes to be represented as in its row, and col, which does the same for column.
td
The td descendant 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)
Copy Code
<table id="CALS">
    <caption>What came first?</caption>
    <thead>
    	<tr>
    		<th scope="col">HEADER 1</th>
    		<th scope="col">HEADER 2</th>
    	</tr>
    </thead>
    <tbody>
    	<tr>
    		<td>BODY CELL 1</td>
    		<td>BODY CELL 2</td>
    	</tr>
    </tbody>
    <tfoot>
    	<tr>
    		<td>FOOTER CELL 1</td>
    		<td>FOOTER CELL 2</td>
    	</tr>
    </tfoot>
</table>

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.

NYS Digital Accessibility MS Teams channel
Have a quick question or need immediate guidance? Jump into our Microsoft Teams channel to chat with the team in real-time.

Report a bug on GitHub
Spotted an issue or a visual glitch? Open a GitHub ticket with a clear description and reproduction steps so we can squash it.