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.

A table example from Discogs, which is used to compare various release versions of the record by country, year of release, catalog number, and so on. (Large preview)

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.

A screenshot of the table example from Discogs shown on a small screen
A previous example shown on a small screen. Without a table head element, the data is difficult to parse and compare. This table could be better implemented using a different approach. (Large preview)

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.

More after jump! Continue reading below ↓

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:

A example of a table on a wide screen with big space between the columns
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. (Large preview)
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.

An example of a table on a wide screen without big space between the columns
By setting the width with fit-content value, we’ve fixed the above-mentioned issue. (Large preview)

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.

An example of a table on a wide screen with a maximum width value that adapts to table contents
By using auto or max-content, we get a maximum width value that adapts to table contents. (Large preview)

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);
}

See the Pen [Responsive table – as is [forked]](https://codepen.io/smashingmag/pen/yLEVLbX) by Adrian Bece.

See the Pen Responsive table – as is [forked] by Adrian Bece.

On complex tables with multiple columns where we cannot rely on fluid sizing and word-break to keep the table readable, we want the table to stretch as far as it needs to display the content optimally and allow users to scroll the table horizontally, so the table remains usable.

See the Pen [Table – scrollbars with dynamic cropping [forked]](https://codepen.io/smashingmag/pen/KKeNKvm) by Adrian Bece.

See the Pen Table – scrollbars with dynamic cropping [forked] by Adrian Bece.

By wrapping the table and applying overflow: auto on the wrapper element, we can add scrollbars to our table when there is not enough space on the screen for the table to fit.

This is useful on complex tables when we want to keep the default table layout and hierarchy for clarityand when we want to allow users to compare the data between cells and easily match them to table headers.

We can use a figure element to do so and add a figcaption for the table title or use another HTML container element with a heading for a title:

<figure>
  <figcaption id="caption">
    <h1>Countries with most population</h1>
  </figcaption>
  <div class="table-wrapper" role="group" aria-labelledby="caption">
    <table>
      <!-- Table contents -->
    </table>
  </div>
</figure>

Either way, this configuration is not usable for users who are using keyboard navigation as the table element is not focusable. We can easily do that by adding a tabindex attribute to the table wrapper element or table element directly (if we aren’t using any wrappers).

<div tabindex="0" class="table-wrapper" role="group" aria-labelledby="caption">
  <table>
     <!-- Table contents -->
  </table>
</div>

Using Shadows To Indicate Possible Scrolling Directions

Browser scrollbars are controlled by the operating system, meaning that they might look and behave differently, depending on the device.

An example of a table where a scrollbar is hidden
Chrome browser on iOS: scrollbar is hidden by default. (Large preview)

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.

An example of a table with a scrollbar displayed
Chrome browser on iOS: scrollbar displays when a user interacts with the element. (Large preview)

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;
}

See the Pen [Table – scrollbars with background [forked]](https://codepen.io/smashingmag/pen/ExRNxpd) by Adrian Bece.

See the Pen Table – scrollbars with background [forked] by Adrian Bece.

Notice how shadows subtly hide and show as we scroll from one edge to another. And we added this nice effect with just a few additional CSS attributes without using JavaScript or additional HTML elements or wrappers.

An example of a table with a scrollbar with shadow
The shadow appears and disappears depending the direction where user can scroll. (Large preview)
An example of a table with a subtle shadow on the right side
On devices where scrollbars are hidden by default, the subtle shadow indicates that a table can be scrolled. (Large preview)

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.

An example of a table with a text that reads 'scroll for more'
(Large preview)

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:

See the Pen [Table – scrollbars with dynamic cropping [forked]](https://codepen.io/smashingmag/pen/xxzRxBB) by Adrian Bece.

See the Pen Table – scrollbars with dynamic cropping [forked] by Adrian Bece.

As the user scrolls the table, either horizontally or vertically, table header elements will become hidden, and the user might start having trouble matching the data to the headers, depending on the table and data complexity. They would have to go back and forth and memorize the data order.

To avoid this issue, we can make the table headers sticky by applying position: sticky and making some style adjustments to fix the background color and borders. This is a great enhancement because position: sticky and style adjustments don’t affect the table style or layout if it’s not scrollable.

See the Pen [Table – fixed table-heads + background [forked]](https://codepen.io/smashingmag/pen/QWxGWXq) by Adrian Bece.

See the Pen Table – fixed table-heads + background [forked] by Adrian Bece.

Combined with the aforementioned “scrolling shadows” effect, we’ve ensured that users can easily scan the table data and have proper scrolling indicators.

A table with scrolling shadows with sticky table headers which remains visible on scroll
A table with scrolling shadows with sticky table headers which remains visible on scroll. (Large preview)

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 properties flex, grid, inline-block, or contents are used. The new Edge (ChromiEdge) follows suit. Firefox still dumps table semantics for only display: 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]](https://codepen.io/smashingmag/pen/bGKBNNr) by Adrian Bece.

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]](https://codepen.io/smashingmag/pen/dyKOYVr) by Adrian Bece.

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]](https://codepen.io/smashingmag/pen/RwJoWQb) by Adrian Bece.

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

Smashing Editorial(yk, il)



Source link