Accessible Front-End Patterns For Responsive Tables (Part 1)
Tables allow us to organize data into grid-like format of rows and columns. Scanning the table in one direction allows users to search and compare the data while scanning in the other direction lets users get all details for a single item by matching the data to their respective table header elements.
Tables often rely on having enough screen space to communicate these data relations effectively. This makes designing and developing more complex responsive tables somewhat of a challenge. There is no universal, silver-bullet solution for making the tables responsive as we often see with other elements like accordions, dropdowns, modals, and so on. It all depends on the main purpose of the table and how it’s being used.
If we fail to consider these factors and use the wrong approach, we can potentially make usability worse for some users.
In this article, we’re going to be strictly focused on various ways we can make tables on the web responsive, depending on the data type and table use-case, so we’re not going to cover table search, filtering, and other similar functionalities.
If you are interested in improving user experience (UX) for tables and other UI elements beyond just responsiveness, make sure to check out Smashing Magazine’s incredibly useful Smart Interface Design Patterns workshop, which covers best practices and guidelines for various UI components, tables included.
Short Primer On Accessible Tables
Before diving into specific responsive table patterns, let’s quickly go over some best practices regarding design and accessibility. We’ll cover some general points in this section and other, more specific ones in later examples.
Design And Visual Features
First, we need to ensure that users can easily scan the table and intuitively match the data to their respective table header elements. From the design perspective, we can ensure the following:
- Use proper vertical and horizontal alignment (“A List Apart” covers this in their article).
- Design a table with clear divisions and optimal spacing between rows and cells.
- Table header elements should stand out and be styled differently from data cells.
- Consider using alternate background color for rows or columns (“zebra stripes”) for easier scanning.
ARIA Roles
We want to include proper ARIA attributes to our table element and its descendants. Applying some CSS styles like display: block
or display: flex
(to create responsive stacked columns) may cause issues in some browsers. In those cases, screen readers interpret the table
element differently, and we lose the useful table semantics. By adding ARIA labels, we can fix the issue and retain the table semantics.
Including these roles in HTML manually could become tedious and prone to error. If you are comfortable about using JavaScript for adding additional markup, and you aren’t using a framework that generates static HTML files, you can use this handy little JavaScript function made by Adrian Roselli to automatically add ARIA roles to table elements:
function AddTableARIA() {
try {
var allTables = document.querySelectorAll("table");
for (var i = 0; i < allTables.length; i++) {
allTables[i].setAttribute("role", "table");
}
var allRowGroups = document.querySelectorAll("thead, tbody, tfoot");
for (var i = 0; i < allRowGroups.length; i++) {
allRowGroups[i].setAttribute("role", "rowgroup");
}
var allRows = document.querySelectorAll("tr");
for (var i = 0; i < allRows.length; i++) {
allRows[i].setAttribute("role", "row");
}
var allCells = document.querySelectorAll("td");
for (var i = 0; i < allCells.length; i++) {
allCells[i].setAttribute("role", "cell");
}
var allHeaders = document.querySelectorAll("th");
for (var i = 0; i < allHeaders.length; i++) {
allHeaders[i].setAttribute("role", "columnheader");
}
// This accounts for scoped row headers
var allRowHeaders = document.querySelectorAll("th[scope=row]");
for (var i = 0; i < allRowHeaders.length; i++) {
allRowHeaders[i].setAttribute("role", "rowheader");
}
// Caption role not needed as it is not a real role, and
// browsers do not dump their own role with the display block.
} catch (e) {
console.log("AddTableARIA(): " + e);
}
}
However, keep in mind the following potential drawbacks of using JavaScript here:
- Users might choose to browse the website with JavaScript turned off.
- The JavaScript file may not be downloaded or may be downloaded much later if the user is browsing the website on an unreliable or slow network.
- If this is bundled alongside other JavaScript code in the same file, an error in other parts of the file might prevent this function from running in some cases.
Adding An a11y-Friendy Title
Adding a title next to the table helps both sighted users and users with assistive devices get a complete understanding of the content.
Ideally, we would include a caption
element inside the table
element as a first child. Notice how we can nest any HTML heading element as a child to maintain the title hierarchy.
<table>
<caption>
<h2>Top 10 best-selling albums of all time</h2>
</caption>
<!-- Table markup -->
</table>
If we are using a wrapper element to make the table scrollable or adding some other functionality that makes the caption
element not ideal, we can include the table
inside a figure
element and use a figcaption
to add a title. Make sure to include a proper ARIA label on either the table element or a wrapper element and link it to a figcaption
element:
<figure>
<figcaption id="caption">Top 10 best-selling albums of all time</figcaption>
<table aria-labelledby="caption"><!-- Table markup --></table>
</figure>
<figure>
<figcaption id="caption">
<h2>Top 10 best-selling albums of all time</h2>
</figcaption>
<div class="table-wrapper" role="group" aria-labelledby="caption" tabindex="0">
<table><!-- Table markup --></table>
</div>
</figure>
There are other accessibility aspects to consider when designing and developing tables, like keyboard navigation, print styles, high contrast mode, and others. We’ll cover some of those in the following sections. For a more comprehensive guide on creating accessible table elements, make sure to check out Heydon Pickering’s guide and Adrian Roselli’s article which is being kept up to date with the latest features and best practices.
Bare-bones Responsive Approach
Sometimes we don’t have to make any major changes to our table to make it responsive. We just need to ensure the table width responds to the viewport width. That can be easily achieved with width: 100%
, but we should also consider setting a dynamic max-width
value, so our table doesn’t grow too wide on larger containers and becomes difficult to scan, like in the following example:
table {
width: fit-content;
}
With the fit-content
value, we ensure that the table doesn’t grow beyond the minimum width required to optimally display the table contents and that it remains responsive.
The table responds to viewport size, and it looks good on small screens, but on wider screens, it becomes difficult to scan due to the unnecessary space between the columns.
We can also ensure that the table max-width
value always adapts to its content. We don’t have to rely on assigning a magic number for each table or wrap the table in a container that constrains the width to a fixed value.
This works well for simple tables that don’t require too much screen space to be effectively parsed and aren’t affected by word-break
. We can even use fluid typography and fluid spacing to make sure these simple tables remain readable on smaller screens.
/* Values generated with Utopia https://utopia.fyi/type/calculator/ */
tbody {
font-size: clamp(1.13rem, calc(0.35rem + 2.19vw), 1.75rem);
}
tbody td {
padding-top: clamp(1.13rem, calc(0.35rem + 2.19vw), 1.75rem);
padding-bottom: clamp(2rem, calc(0.62rem + 3.9vw), 3.11rem);
}
This is important to know because on some devices, like smartphones and tablets, scrollbars aren’t visible right away, and users might get the impression that the table is not scrollable.
Lea Verou and Roman Komarov have suggested using “scrolling shadows” to subtly indicate the scrolling direction using gradient background and background-attachment
property. Using this property, we can set background gradient behavior when scrolling. We also use linear gradients as edge covers for shadows, so we gradually hide the shadow when the user has reached an edge and cannot scroll in that direction anymore.
.table-wrapper {
overflow: auto;
background:
linear-gradient(90deg, var(--color-background) 20%, rgba(255, 255, 255, 0)),
linear-gradient(90deg, rgba(255, 255, 255, 0), var(--color-background) 80%)
100% 0,
radial-gradient(farthest-side at 0 0%, var(--color-shadow), rgba(0, 0, 0, 0)),
radial-gradient(farthest-side at 100% 0%, var(--color-shadow), rgba(0, 0, 0, 0))
100% 0;
background-repeat: no-repeat;
background-size: 20% 200%, 20% 200%, 8% 400%, 8% 400%;
background-attachment: local, local, scroll, scroll;
}
Keep in mind that background-attachment
property is not supported on iOS Safari and a few other browsers, so make sure to either provide a fallback or remove the background on unsupported browsers. We can also provide helpful text next to the table to make sure users understand that the table can be scrolled.
Forcing Table Cropping
We can also dynamically set the table column width to enforce table cropping mid-content, so the user gets a clear hint that the table is scrollable. I’ve created a simple function for this example. The last column will always get cropped to 85% of its size, and we’ll reduce the number of visible columns by one if we cannot show at least 5% of the column’s width.
function cropTable(visibleCols) {
const table = document.querySelector("figure");
const { width: tableWidth } = table.getBoundingClientRect();
const cols = table.querySelectorAll("th, td");
const newWidth = tableWidth / visibleCols;
// Resize columns to fit a table.
cols.forEach(function(col) {
// Always make sure that col is cropped by at least 15%.
col.style.minWidth = newWidth + (newWidth * 0.15) + "px";
});
// Return if we are about to fall below min column count.
if (visibleCols <= MIN_COLS) {
return;
}
// Measure a sample table column to check if resizing was successful.
const { width: colWidth } = cols[0].getBoundingClientRect();
// Check if we should crop to 1 column less (calculate new column width).
if (colWidth * visibleCols > tableWidth + newWidth * 0.95) {
cropTable(visibleCols - 1);
}
}
This function might need to be adjusted to a more complex use case. Check the example below and see how the table column width responds to window resizing:
Stacking Approach (Rows To Blocks)
The stacking approach has been a very popular pattern for years. It involves converting each table row into a block of vertically stacked columns. This is a very useful approach for tables where data is not comparable or when we don’t need to highlight the hierarchy and order between items.
For example, cart items in a webshop or a simple contacts table with details — these items are independent, and users primarily scan them individually and search for a specific item.
As mentioned before, converting the table rows to blocks usually involves applying display: block
on small screens. However, as Adrian Roselli has noted, applying a display
property overrides native table semantics and makes the element less accessible on screen readers. This discovery was jarring to me, as I’ve spent years crafting responsive tables using this pattern without realizing I was making them less accessible in the process.
It’s not all bad news, as Adrian Roselli notes the following change for Chrome version 80:
Big progress. Chrome 80 no longer drops semantics for HTML tables when the
display
propertiesflex
,grid
,inline-block
, orcontents
are used. The new Edge (ChromiEdge) follows suit. Firefox still dumps table semantics for onlydisplay: contents
. Safari dumps table semantics for everything.— Adrian Roselli
For this example, we’ll use display: flex
instead of using display: block
for our stacking pattern. This is not an ideal solution as other browsers might still drop table semantics, so make sure to test the accessibility on various browsers and devices.
/* Small screen width styles */
table, tbody, tbody tr, tbody td, caption {
display: flex;
flex-direction: column;
width: 100%;
word-break: break-all;
}
See the Pen Table – stacked [forked] by Adrian Bece.
Accordion
The stacking pattern might look nice initially and seems to be an elegant solution from a design perspective. However, depending on the table and data complexity, this pattern might significantly increase page height, and the user might have to scroll longer to reach the content below the table.
One improvement I found interesting was to show the primary data column (usually the first column) and hide the less important data (other columns) under an accordion. This makes sense for our example, as users would first look for a name by contact and then scan for their details in the row.
<tr>
<td onclick="toggle()">
<button aria-label="Expand contact details">
<!-- Icon -->
</button>
<!-- Main content-->
</td>
<td><!-- Secondary content--></td>
<td><!-- Secondary content--></td>
<td><!-- Secondary content--></td>
</tr>
We’ll assume that the first table column contains primary data, and we’ll hide other columns unless a row-active
class is applied:
/* Small screen width styles */
thead tr > *:not(:first-child) {
display: none;
}
tbody,
tbody tr,
tbody td {
display: flex;
flex-direction: column;
word-break: break-all;
}
tbody td:first-child {
flex-direction: row;
align-items: center;
}
tbody tr:not(.row-active) > *:not(:first-child) {
max-width: 0;
max-height: 0;
overflow: hidden;
padding: 0;
}
Now we have everything in place for showing and hiding table row details. We also need to keep in mind the screen reader support and toggle the aria-hidden
property to hide secondary info from screen readers. We don’t need to toggle the ARIA property if we’re toggling the element visibility with the display
property:
function toggle() {
const row = this.window.event.target.closest("tr");
row.classList.toggle("row-active");
const isActive = row.classList.contains("row-active");
if (isActive) {
const activeColumns = row.querySelectorAll("td:not(:first-child)");
activeColumns.forEach(function (col) {
col.setAttribute("aria-hidden", "false");
});
} else {
const activeColumns = row.querySelectorAll(`td[aria-hidden="false"]`);
activeColumns.forEach(function (col) {
col.setAttribute("aria-hidden", "true");
});
}
We’ll assign this function to the onclick
attribute on our main table column elements to make the whole column clickable. We also need to assign proper ARIA labels when initializing and resizing the window. We don’t want incorrect ARIA labels applied when we resize the screen between two modes.
function handleResize() {
const isMobileMode = window.matchMedia("screen and (max-width: 880px)");
const inactiveColumns = document.querySelectorAll(
"tbody > tr > td:not(:first-child)"
);
inactiveColumns.forEach(function (col) {
col.setAttribute("aria-hidden", isMobileMode.matches.toString());
});
}
//On window resize
window.addEventListener("resize", handleResize);
// On document load
handleResize();
See the Pen Table – accordion [forked] by Adrian Bece.
This approach significantly reduces table height on smaller screens compared to the previous example. The content below the table would now easily be reachable by quickly scrolling past the table.
Toggleable Columns Approach
Going back to our scrollable table example, in some cases, we can give users an option to customize the table view by allowing them to show and hide individual columns, temporarily reducing table complexity in the process. This is useful for users that want to scan or compare data only by specific columns.
We’ll use a checkbox form and have them run a JavaScript function. We’ll only have to pass an index of the column that we want to toggle. We’ll have to hide both the columns in data rows in a table body and a table header element:
function toggleRow(index) {
// Hide a data column for all rows in the table body.
allBodyRows.forEach(function (row) {
const cell = row.querySelector(`td:nth-child(${index + 1})`);
cell.classList.toggle("hidden");
});
// Hide a table header element.
allHeadCols[index].classList.toggle("hidden");
}
This is a neat solution if you want to avoid the stacking pattern and allow users to easily compare the data but give them options to reduce the table complexity by toggling individual columns. In this case, we’re using a display
property to toggle the visibility, so we don’t have to handle toggling ARIA labels.
See the Pen Responsive table – column toggle [forked] by Adrian Bece.
Conclusion
Table complexity and design depend on the use case and the data they display. They generally rely on having enough screen space to display columns in a way user can easily scan them. There is no universal solution for making tables responsive and usable on smaller screens for all these possible use cases, so we have to rely on various patterns.
In this article, we’ve covered a handful of these patterns. We’ve focused primarily on simple design changes with a scrolling table pattern and a stacking pattern and began checking out more complex patterns that involve adding some JavaScript functionality.
In the next article, we’ll explore more specific and complex responsive table patterns and check out some responsive table libraries that add even more useful features (like filtering and pagination) to tables out of the box.
References
- “Tables, CSS Display Properties, And ARIA”, Adrian Roselli
- “A Responsive Accessible Table”, Adrian Roselli
- “Functions To Add ARIA To Tables And Lists”, Adrian Roselli
- Data Tables, Heydon Pickering
- “Table Design Patterns On The Web”, Chen Hui Jing
- “Pure CSS Scrolling Shadows With background-attachment: local”, Lea Verou
- Tables Caption & Summary, Web Accessibility Initiative
- Tables Tips And Tricks, Web Accessibility Initiative