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:
- 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-labelledbyto point to theidof a heading that directly precedes the table
- Use
- 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
- Use
- 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
scopeattributes is highly recommended as a best practice
- Use
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:
<thead>- Contains column headers (one or more<tr>with<th>cells)<tfoot>- Contains footer rows (renders at bottom of table)<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
Buttons and links with identical text labels
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>Keyword search
Use an input field with a descriptive label and a submit button:
<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.
Skip links for tables
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>CSS for skip links
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>oraria-labelledby - All headers use
<th>with appropriatescopeattributes - 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
For help including design reviews, code reviews, or additional training contact the ACCESS team using the ACCESS team’s intake form.
- WCAG 2.1 Success Criterion 1.3.1: Info and Relationships (Level A) (external link)
- WCAG 2.1 Success Criterion 1.3.2: Meaningful Sequence (Level A) (external link)
- WCAG 2.1 Success Criterion 1.4.1: Use of Color (Level A) (external link)
- WCAG 2.1 Success Criterion 1.4.4: Resize Text (Level AA) (external link)
- WCAG 2.1 Success Criterion 1.4.10: Reflow (Level AA) (external link)
- WCAG 2.1 Success Criterion 2.1.1: Keyboard (Level A) (external link)
- WCAG 2.1 Success Criterion 2.4.3: Focus Order (Level A) (external link)
- WCAG 2.1 Success Criterion 2.4.6: Headings and Labels (Level AA) (external link)
- WCAG 2.2 Success Criterion 2.4.12: Focus Not Obscured (Minimum) (Level AA) (external link)
- WCAG 2.2 Success Criterion 2.5.8: Target Size (Minimum) (Level AA) (external link)
- WCAG 2.1 Success Criterion 4.1.2: Name, Role, Value (Level A) (external link)
- W3C WAI Tables Tutorial (external link)
- WCAG 2.2 Guidelines (external link)
- H63: Using the scope attribute (external link)
- H43: Using id and headers attributes (external link)
- ANDI tool
- Text spacing bookmarklet