Creating HTML tables for websites and applications

This guide describes in detail the coding best-practices used for the "Live Training: Creating HTML tables for websites and applications".

The following content summarizes the accessibility best-practices that are demonstrated in the following training course: "Creating HTML tables for websites and applications".

Are you a developer or tester that works with HTML web pages or applications?

This training covers accessible HTML table structure, semantic markup, sorting and filtering, pagination, responsive design with browser zoom support, and keyboard and screen reader interaction. You’ll learn best practices using HTML, CSS, and vanilla JavaScript code examples.

When to use tables

Use HTML tables for:

  • Displaying tabular data with logical relationships between rows and columns
  • Data that needs to be compared across categories
  • Structured information where row and column headers provide context

Do not use HTML tables for:

  • Page layout (use CSS Flexbox or Grid instead)
  • Creating multi-column layouts
  • Tables within tables also called "nested tables (external link)" are strongly discouraged
  • Positioning images or text
  • Only exception: HTML email templates for email client compatibility may use table layout using role="presentation"

Historical context: In the past, tables were commonly used for page layout before CSS layout methods existed. Modern web development uses CSS for all layout purposes, reserving tables exclusively for tabular data.

Table structure and semantic markup

Required table elements

All accessible HTML tables must include:

  1. Caption or aria-labelledby
    • Use <caption> tag as the first child of the table element
    • The caption should be unique and descriptive
    • Alternative: Use aria-labelledby to point to the id of a heading that directly precedes the table
  2. Table headers (<th>)
    • Use <th> for all header cells (column and/or row headers)
    • Use <td> for all data cells
    • Headers must be descriptive and meaningful
  3. Scope attribute
    • Use scope="col" for column headers
    • Use scope="row" for row headers
    • Note: While modern screen readers can sometimes infer scope without the attribute, using explicit scope attributes is highly recommended as a best practice

Table sectioning elements

The <thead>, <tbody>, and <tfoot> elements group table rows into logical sections:

Benefits:

  • Semantic meaning: Better represents data structure for accessibility and screen readers
  • Styling: Easier to apply specific CSS to different table sections
  • Printing: Enables header and footer repetition across printed pages (using print media queries)
  • Independent scrolling: Allows body content to scroll while keeping headers fixed (use cautiously—can cause issues at 400% zoom, on mobile, or with screen magnifiers)

Structure order:

  1. <thead> - Contains column headers (one or more <tr> with <th> cells)
  2. <tfoot> - Contains footer rows (renders at bottom of table)
  3. <tbody> - Contains main data rows (can have multiple <tbody> elements, but only one <thead> and one <tfoot>)

These elements do not affect layout by default; their impact comes through CSS styling and browser functionality.

Handling symbols and abbreviations in headers

When using symbols (%, #, @, ., /, -) in table headers or cells, follow these patterns:

Option 1: Symbol in header only

<th>
  <span aria-hidden="true">%</span>
  <span class="visually-hidden">Percentage</span>
</th>

Option 2: Caption provides context

<caption>Percentage of college graduates by state</caption>
<th>
  College graduates
  <span aria-hidden="true">%</span>
</th>

In data cells:

<td>
  5
  <span aria-hidden="true">%</span>
  <span class="visually-hidden">Percent</span>
</td>

This technique relates to WCAG 2.1 Success Criterion 1.3.1: Info and Relationships.

Complex tables

For tables with multiple header levels or irregular structures, use the id and headers attributes:

<th id="name" scope="col">Name</th>
<th id="city" scope="col">City</th>

<td headers="name city">Boston</td>

For more complex table patterns, see W3C WAI Tables Tutorial (external link).

WCAG Techniques:

Best practices for table size

Maximum recommended columns: 6-7 columns (including action column with buttons) - For tables requiring more columns, consider building a column selector control - Default view shows 6 columns; users can select additional columns to display

Maximum recommended rows per page: 10 rows - Implement pagination for larger datasets - Provide a dropdown to control entries per page (do not exceed 10 per page)

Interactive elements in tables

When tables contain multiple interactive elements (Edit, Delete, View buttons or links) that have identical text labels, each must have unique accessible names.

Use aria-labelledby technique:

<table>
  <caption>User accounts</caption>
  <thead>
    <tr>
      <th scope="col">Name</th>
      <th scope="col">Email</th>
      <th scope="col">Actions</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td id="user1">Jane Smith</td>
      <td>jane@example.com</td>
      <td>
        <button type="button" id="edit1" aria-labelledby="edit1 user1">Edit</button>
        <button type="button" id="delete1" aria-labelledby="delete1 user1">Delete</button>
      </td>
    </tr>
    <tr>
      <td id="user2">Mike Thompson</td>
      <td>mike@example.com</td>
      <td>
        <button type="button" id="edit2" aria-labelledby="edit2 user2">Edit</button>
        <button type="button" id="delete2" aria-labelledby="delete2 user2">Delete</button>
      </td>
    </tr>
  </tbody>
</table>

View Responsive Table CodePen example (external link)

Checkboxes in tables

When using checkboxes for row selection (e.g., for bulk deletion): - Place checkboxes in the first column - Each checkbox must have a proper <label> element - Use aria-labelledby to create unique labels for each checkbox

Space-saving action menus

For tables with limited space, use a single "Actions" button that opens a dropdown menu:

<button type="button" id="actions1" aria-labelledby="actions1 user1">Actions</button>
<!-- Dropdown shows: Edit, View, Delete -->

Important: Avoid icon-only buttons as they exclude voice/speech activation software users. Always include visible text labels or use the dropdown pattern.

Lists within table cells

You can include semantic lists within table cells:

<td>
  <ul>
    <li>Item 1</li>
    <li>Item 2</li>
  </ul>
</td>

Empty table cells

For HTML tables on web pages

Sighted users: Visual indicators are optional. Cells can appear blank or empty.

Screen reader users: Screen reader-only text is required.

Implementation options:

Option 1: Empty cell with visually hidden text

<td>
  <span class="visually-hidden">data not shown</span>
</td>

Option 2: Cell with visual dash symbol

<td>
  <span class="visually-hidden">data not shown</span>
  <span aria-hidden="true">-</span>
</td>

CSS for visually hidden text:

.visually-hidden {
  position: absolute !important;
  height: 1px;
  width: 1px;
  overflow: hidden;
  clip: rect(1px, 1px, 1px, 1px);
  clip-path: none;
  left: -1px;
}

Best practices:

  • Use meaningful labels like "data not shown," "not applicable," or "no data available"
  • Avoid technical abbreviations like "NA" unless explained in a table legend
  • If using visual symbols, include a legend above the table explaining their meaning
  • For “null” values from databases, use front-end logic to display meaningful text
  • Use “0” (zero) for actual zero results: When data exists and the value is legitimately zero, display "0". Do not use "0" for null or missing data from the database.

Table legend for symbols

When using icons or symbols in tables: 

  • Add a legend above the table explaining what each symbol means
  • Non-interactive symbols should have tooltips on focus/hover
  • Voice/speech activation software users still need the legend (tooltips alone aren’t sufficient)
  • Never use color alone to indicate state or meaning
  • Always combine color with text, icons, or patterns

Filtering, sorting, and pagination

Filtering and sorting controls

Recommended approach: Place filter and sort controls above the table, not in table headers.

Why avoid buttons in table headers:

  • Never fully accessible
  • Confusing for keyboard and screen reader users
  • Difficult to make clear and intuitive

While sortable column headers using <button> elements and aria-sort are technically achievable, this pattern is not recommended as a primary approach. It does not work when a table uses a responsive card layout, sort direction is difficult to communicate clearly to all users, and the pattern requires careful implementation to avoid verbose or confusing screen reader announcements. If your table must use sortable column headers — for example when integrating with a third-party component or an existing design system — refer to the W3C ARIA Authoring Practices sortable table example (external link) for the correct implementation using aria-sort, <button> inside <th>, and an off-screen sort description appended to the table heading.

Better pattern:

CodePen for responsive table with external sort

<form>
  <div class="table-controls">
    <label for="filter">Filter by:</label>
    <input type="text" id="filter" />
    
    <label for="sort">Sort by:</label>
    <select id="sort">
      <option value="name">Name</option>
      <option value="date">Date</option>
    </select>
    
    <button type="submit">Apply filter</button>
  </div>
</form>

Use an input field with a descriptive label and a submit button:

CodePen for search box

<form>
  <label for="search">Search table:</label>
  <input type="text" id="search" />
  <button type="submit">Search</button>
</form>

External table controls

Common controls to include above or below tables:

  • Filter/sort widgets
  • Results count (e.g., "Showing 1-10 of 247 results")
  • Pagination controls - Download data button (CSV, Excel, etc.)
  • Keyboard jump links (skip to table, skip past table)

Pagination examples

The element you use for pagination controls depends on whether the page reloads when a user navigates to a new page.

Use <a> elements when each pagination item navigates to a new URL and triggers a full page reload. This is the correct pattern for server-side implementations in PHP, Python, Java, or similar. Use <button> elements when the page does not reload — for example in a React or Angular application where JavaScript updates the view in place. Buttons cannot navigate to a URL, so using them for server-side pagination would break keyboard and screen reader navigation.

Both examples use aria-current="page" on the active page, visually hidden text to give page number buttons a meaningful accessible name, and the native disabled attribute (buttons) or ads-disabledLink CSS class (links) to handle unavailable controls. The button example also includes a vanilla JavaScript implementation showing live region announcements and focus management after a page change.

Add skip links to tables that contain interactive elements in every row — such as action buttons, links, or checkboxes — so keyboard users can jump past the rows to the next section of the page, and return to the top of the table without tabbing back through every row.

Skip links must follow WCAG 2.4.4 Link Purpose — the link text must clearly describe where the link goes. Skip links and their return links must also follow WCAG 2.5.3 Label in Name — the visible text must appear at the start of the accessible name.

Skip links are hidden by default and appear only when they receive keyboard focus. Place each skip link immediately before the element it skips. Place each return link immediately after the table or pagination controls.

Any element that receives programmatic focus as a skip link target — such as an <h2>, <div>, or <nav> — must have tabindex="-1". This allows the browser to move focus to that element without adding it to the regular tab order. After the skip link is activated, the user's next Tab keypress starts from the new location rather than the top of the page.
Make sure that you show a visible focus outline when the element receives focus. 

Use the existing ads-visually-hidden class to append a unique table name to skip link text when the page contains more than one table. This keeps visible link text short while providing a unique accessible name for screen reader users browsing the links list.

Single table (no pagination)

When only one table is on the page, no unique name is needed — the context is already clear.

HTML

<h2 id="table-heading" tabindex="-1">Table heading</h2>
 
<!-- Skip link — place immediately after the table heading --> 
<a href="#after-table" class="ads-table-skip-link">
	Skip table
</a> 

<table>...</table> 
 
<!-- Skip target and return link — place immediately after the table --> 
<div id="after-table" tabindex="-1">
	<a href="#table-heading" class="ads-table-return-link">Return to top of table</a>
	<!-- container below table that may or may not contain other items or action buttons --> 
</div> 

Multiple tables (no pagination)

When more than one table is on the page, append the table name using ads-visually-hidden so each link is unique for screen reader users while the visible text stays consistent.

HTML

<h2 id="employee-heading" tabindex="-1">Employee Directory</h2> 

<!-- Skip link --> 
<a href="#after-employees" class="ads-table-skip-link">
   Skip table <span class="ads-visually-hidden">for Employee Directory</span> 
</a>

<table>...</table>  

<!-- Skip target and return link --> 
<div id="after-employees" tabindex="-1">
	<a href="#employee-heading" class="ads-table-return-link">
   	Return to top of table <span class="ads-visually-hidden">for Employee Directory</span>
	</a>
	<!-- container below table that may or may not contain other items or action buttons --> 
</div>

Table with pagination

When a table has pagination controls, the skip link should take the user directly to the pagination rather than past the entire table. Use a <nav> element with a descriptive aria-label as the skip target.

HTML

<h2 id="payroll-heading" tabindex="-1">Payroll Details</h2>

<!-- Skip link --> 
<a href="#payroll-pagination" class="ads-table-skip-link"> 
  Skip to table pagination <span class="ads-visually-hidden">for Payroll Details</span> 
</a> 
 
<table>...</table> 

<!-- Skip target: pagination nav --> 
<nav id="payroll-pagination" aria-label="Payroll Details pagination" tabindex="-1"> 
  ... 
</nav> 

<!-- Return link is optional if the focus moves to the top of the page after activating the pagination — placed after the pagination controls --> 
<a href="#payroll-heading" class="ads-table-return-link"> 
  Return to top of table <span class="ads-visually-hidden">for Payroll Details</span> 
</a>

Skip links use position: absolute with a large negative left value to hide them off-screen by default. On :focus-visible they return to the normal layout flow and become visible. Do not use display: none or visibility: hidden — those techniques remove the element from the accessibility tree and prevent it from receiving focus.

CSS

/* Hidden by default — appears only on keyboard focus */ 
.ads-table-skip-link:not(:focus-visible), 
.ads-table-return-link:not(:focus-visible) { 
  position: absolute; 
  left: -9999px; 
  width: 1px; 
  height: 1px; 
  overflow: hidden; 
} 

/* Visible on focus */ 
.ads-table-skip-link:focus-visible, 
.ads-table-return-link:focus-visible { 
  position: relative; 
  display: inline-block; 
  width: auto; 
  height: auto; 
  margin: .625rem 0; 
  padding: .5rem 1.25rem; 
  background-color: #1a1a1a; 
  color: #ffffff; 
  text-decoration: none; 
  font-weight: bold; 
  border-radius: 4px; 
}

Note: Global focus outline styles already cover the focus ring — do not redefine outline or outline-offset on these classes.

Responsive design and browser zoom

Browser zoom requirements

Tables must support browser zoom up to 400% without:

  • Truncating or clipping content
  • Breaking layout or functionality

Note: Data tables are an exception to the WCAG 2.1 Success Criterion 1.4.10: Reflow, which means horizontal scrolling is allowed for data tables at high zoom levels.

Text resizing: Content must remain readable when text is resized to 200%.

Responsive card layout

For mobile or narrow viewports, consider converting table rows into card layouts:

Benefits:

  • Better mobile experience
  • Avoids horizontal scrolling
  • Easier to scan on small screens

Implementation: Use CSS media queries to transform table layout into stacked cards below a certain breakpoint.

Sticky headers and columns

Pros:

  • Keep context visible while scrolling
  • Helpful for large datasets

Cons:

  • Can cause issues starting at 150% browser zoom and higher
  • Problematic on mobile devices
  • May interfere with screen magnifiers
  • Particularly important to avoid when columns contain similar content (dates, times, numbers)

Recommendation: Use a CSS media query at 1024px viewport width or below to remove sticky headers and sticky columns. This prevents accessibility issues at higher zoom levels and on mobile devices.

Horizontal scrolling

Data tables are allowed to have horizontal scrolling (exception to WCAG 2.1 Success Criterion 1.4.10: Reflow). When horizontal scrolling is present:

  • Ensure the scrollable area is keyboard accessible
  • Provide clear visual indication that content extends off-screen
  • Note that some browsers (particularly on iPad/iPhone) do not show a visible scrollbar until the user touches the screen, which can make horizontal scrolling less discoverable for touch screen and keyboard-only users

Data table with horizontal scroll

For wide data tables that would lose meaning if reflowed into a card or stacked layout, use a horizontal scroll container instead. This keeps all columns and their relationships intact at any viewport width or browser zoom level.

Data tables are a recognized exception to WCAG 1.4.10 Reflow (external link). Horizontal scrolling is permitted when reflowing the table would obscure the relationship between column headers and data cells.

Use this pattern when:

  • The table has more columns than can comfortably fit on screen at the target viewport width
  • The data loses meaning if columns are collapsed or stacked, such as comparison data, financial figures, or results with multiple attributes per row

Scroll wrapper: Wrap the table in a <div> with overflow-x: auto, role="region", aria-labelledby, and tabindex="0". The overflow-x: auto shows a horizontal scrollbar only when the table overflows its container. The role="region" with aria-labelledby exposes the wrapper as a named landmark so screen reader users can identify it and navigate to it from the landmarks list. The tabindex="0" places the wrapper in the natural tab order — without it, keyboard-only users would have no way to reach content that overflows off-screen. Do not add aria-label to the wrapper when aria-labelledby is already present, as this causes the name to be announced twice.

Accessible name: Place an <h2> or appropriate heading directly above the scroll wrapper and reference its id using aria-labelledby on the wrapper <div>. Do not place aria-labelledby on the <table> element itself, as this causes duplication — the name would be announced when focus enters the region and again when the user tabs into the table. This approach was confirmed through real-world testing with NVDA.

Table minimum width: Set min-width on the table element to prevent columns from collapsing. When the table width exceeds the wrapper width, the horizontal scrollbar appears automatically.

Dynamic heading text: When the table results change (for example after a sort or filter is applied), use JavaScript to update the <h2> heading text to describe the current state, such as the active search criteria or sort order. This ensures screen reader users are informed of changes without having to re-read the table.

View data table with horizontal scroll CodePen example (external link)

Data table example with frozen header row and frozen first column

For data tables that require both a frozen column header row and a frozen first column, use a combination of CSS position: sticky and a scroll wrapper with both overflow-x: auto and overflow-y: auto.

Use this pattern when:

  • The table has 6 or more columns and the first column contains row header values (such as a year or category) that users need to see while scrolling right
  • The table has enough rows that the column headers would scroll out of view when the user increases browser zoom

Accessible name: Use an <h2> or appropriate heading element directly above the scroll wrapper and reference its id using aria-labelledby on the wrapper <div>. Do not use a <caption> element for this pattern — a <caption> renders inside the scroll container and may scroll out of view at high zoom levels. Do not also add aria-label to the wrapper when aria-labelledby is already present, as this causes the name to be read twice by screen readers.

Scroll wrapper: Apply role="region", aria-labelledby, and tabindex="0" to the wrapper <div>. The tabindex="0" places the wrapper in the natural tab order so keyboard-only users can focus it and use arrow keys to scroll. Use tabindex="-1" only when JavaScript is managing focus explicitly.

Sticky header row: All <th> cells in <thead> use position: sticky; top: 0 with a z-index of 2. An explicit background-color is required — without it, scrolling body rows would show through the frozen header.

Sticky first column: Row header cells (<th scope="row"> in <tbody>) use position: sticky; left: 0 with a z-index of 1. An explicit background-color and a border-right separator are required.

Corner cell: The first <th> in <thead> must use position: sticky with both top: 0 and left: 0, and a z-index of 3 so it renders above both the frozen row and frozen column when scrolling in either direction.

Vertical scroll and browser zoom: Set max-height: min(80vh, 500px) on the wrapper. A fixed px value does not work because browser zoom does not change CSS pixel values — the wrapper would never overflow vertically and the sticky header row would not activate. The min() function uses the 500px ceiling on large screens and switches to the viewport-relative 80vh value at high zoom levels when the CSS viewport shrinks, triggering the vertical scrollbar and locking the sticky header.

View data table with frozen header row and first column CodePen example (external link)

Screen reader navigation

How screen reader users navigate tables

Screen reader users have specific keyboard commands (shortcuts) to navigate tables:

  • Navigate by column
  • Navigate by row
  • Read column headers
  • Read row headers
  • Jump to specific cells

This is why proper table markup is critical:

  • <th> elements define headers
  • scope attributes define header relationships
  • <caption> provides table context (especially important when there are multiple tables on a page)
  • Empty cells need screen reader text

Color and visual indicators

Do not use color alone to convey information in tables.

WCAG 2.1 Success Criterion 1.4.1: Use of Color requires that color is not the only visual means of conveying information.

Examples of violations:

  • Red/green to indicate pass/fail (without text or unique icons)
  • Color-coded cells without additional indicators

Accessible alternatives:

  • Include text labels ("Pass"/"Fail")
  • Add unique icons with appropriate alternative text for different states
  • Use patterns in addition to color
  • Provide a legend explaining the combination of icons, text, and color used in the table

File export accessibility

CSV files

  • Empty cells are allowed
  • CSV is plain text format - no accessibility steps required
  • However, avoid entire rows or columns being empty (confusing for non-sighted users)
  • The data must be accessible when displayed in HTML table format on your website

Excel files

  • Empty cells are allowed
  • Use Excel’s "Format as Table" option to define headers
  • Avoid merged or split cells
  • Ensure clear header row or column
  • Note: Once formatted as a table, completely empty rows/columns can confuse screen reader users

Word, PowerPoint, and PDF files

  • Empty cells are not allowed
  • Must use placeholders ("NA", "not available", etc.)
  • PDF documents require manual tagging and cannot be made accessible using backend logic alone
  • Recommendation: Use Word documents instead of PDFs when possible, as they can be made accessible with backend logic

User-initiated print-to-PDF

  • Print-to-PDF initiated by users is exempt from accessibility guidelines

Documents training resources

Testing accessible tables

Developer testing checklist

  • Table contains only tabular data (not used for layout)
  • Table has unique <caption> or aria-labelledby
  • All headers use <th> with appropriate scope attributes
  • All data cells use <td>
  • Interactive elements that have repeated visible text (e.g., "View", "Edit", "Delete") must have unique accessible names using aria-labelledby
  • Empty cells include screen reader text
  • Color is not the only indicator of meaning
  • Table instructions/legend provided when needed
  • No merged cells (except where semantically appropriate)
  • No nested tables (except email templates)

Browser zoom testing

Test at browser zoom levels from 100% to 400%:

  • At all zoom levels (100%, 150%, 200%, 300%, 400%), text remains readable
  • At all zoom levels, content is not truncated or clipped
  • Content adapts appropriately (card layout or horizontal scroll for data tables)

Mobile testing

  • Test in mobile viewport (320px - 428px wide)
  • Verify card layout or appropriate responsive behavior
  • Touch targets are at least 44x44 pixels

Keyboard testing

  • All interactive elements are keyboard accessible
  • Focus indicators are visible
  • Tab order is logical
  • Filter/sort controls work with keyboard only

Screen reader testing

Note: Screen reader testing should only be performed by testers who have been properly trained in screen reader usage. For testers who are not trained in screen readers, use the ANDI accessibility tool to verify proper table structure and the browser developer tools to verify unique accessible names for repeated elements.

For trained screen reader testers using NVDA or JAWS (Windows) or VoiceOver (Mac):

  • Table is announced as a table
  • Caption/label is read
  • Column headers are announced when navigating cells
  • Row headers are announced (if applicable)
  • Interactive elements have unique names
  • Empty cells have meaningful labels
  • Table navigation commands work correctly

ANDI testing

Use the ANDI accessibility testing tool:

  • Run ANDI on the page containing the table
  • Review structure mode to verify proper table markup
  • Check that all headers are properly identified
  • Verify accessible names for interactive elements

Additional Resources

Help Us Improve Mass.gov  with your feedback

Please do not include personal or contact information.
Feedback