A Responsive Design Approach for Complex, Multicolumn Data Tables

April 2020 note: Hi! Just a quick note to say that this post is pretty old, and might contain outdated advice or links. We're keeping it online, but recommend that you check newer posts to see if there's a better approach.

In responsive web design, one of the toughest design problems to solve is how format complex tabular data for display on smaller screens. In this post, we’ll explore an experimental approach to rendering a complex table, using progressive enhancement and responsive design methods, that displays comfortably at a wide range of screen sizes, provides quick access to the data, and preserves the table structure so that data can still be compared across columns.

We’ve been batting around the idea of making tables responsive for awhile.

Our initial attempts to make table data palatable on small screens include showing a thumbnail image that links to the data or a canvas-based chart; others have developed responsive CSS workarounds that display a definition list, using either list or table markup. Which approach to take depends on the type of data. For example, structured data where each row is a unique object or entity—say, business contacts, or favorite Netflix movies—are well-suited to a definition list style on small screens, because header and cell data can be displayed as simple label and value pairs (i.e., Name: Maggie Wachs, Company: Filament Group…). The switch to a chart or other visualization on smaller screens works well for a simple numeric comparison of a single value. And snapping down to a thumbnail image that launched a full table to pan and zoom was an okay fallback in lieu of no data at all.

But we encountered a scenario that didn’t quite work with any of the above solutions: a table of complex of financial data with 6-8 related data points, where comparisons and trends among columns are important to see. Visual relationships between headings and cells, and between neighboring columns, are crucial to understanding the data and would be lost on smaller screens if we displayed a list or chart.

We needed a happy medium: a way to keep the basic table structure in place—with headings above, and whole columns that sit side-by-side—and simultaneously display a manageable amount of data at a size that’s comfortably readable.

A table for every screen

Permalink to 'A table for every screen'

The approach we devised starts with a full data set, and uses a simple priority-based class designation to display a manageable subset of data columns for common target screen sizes, and also gives the user control to change column visibility easily.

It’s probably easier to explain with a concrete example: we’ll use a table that lists technology companies and their stock prices and several stock performance metrics. Each row displays data for a single company; columns organize data points by type for comparison. View the demo.

All data columns will display on desktops and tablets in landscape orientation, but only a subset will fit comfortably on anything smaller. So the first order of business is to identify which columns of data are essential to see at all screen widths by default, or optional (shown only when space allows). In our table of tech company stocks, the data is somewhat meaningless without the company name, current stock value, or most recent change, so we’ll consider those essential. The trade time, previous close, and open values would be nice to see if the screen can fit those columns, so we’ll make them optional. The remaining data—bid, ask, and 1-year target estimate—are less important in relative terms, so they will appear on only the widest screens.

In this case, we want users to have the last word regarding which columns to show, so we’ll also create a custom menu that lets them choose which columns to display.

The final result is a table that displays a limited set of columns on smaller screens, and provides quick access to data that’s hidden because of space constraints (view the demo). To accomplish this, we’ll use progressive enhancement to ensure that we’re serving a usable experience to all devices. We’ll start with well-formed, semantic table markup and very basic CSS, and then if the browser is capable, apply JavaScript and enhanced CSS (including media queries) to conditionally show a larger number of columns as screen space allows.

Markup

Permalink to 'Markup'

A basic table — with consecutive columns and descriptive headings — is an efficient way to display a complex data set; data arranged into columns and rows are easy to scan and compare. So an HTML table is the clear markup choice for our financial data.

We’ll start with a well-formed table that contains a thead for the heading row, followed by a tbody for the cells.

<table cellspacing="0" id="tech-companies">
   <thead>
      <tr>
         ...header cells...
      </tr>
   </thead>
   <tbody>
      ...rows of data...
   </tbody>
</table>

As we fill in the content, we’ll add descriptive classes to identify the essential and optional content. We’ll assign these classes only to the headers; later, we’ll write a little JavaScript to map these headers to their respective columns of data.

<thead>
   <tr>
      <th class="essential persist">Company</th>
      <th class="essential">Last Trade</th>
      <th class="optional">Trade Time</th>
      <th class="essential">Change</th>
      <th class="optional">Prev Close</th>
      <th class="optional">Open</th>
      <th>Bid</th>
      <th>Ask</th>
      <th>1y Target Est</th>
   </tr>
   ...
</thead>

Notice that we added a second class to the Company header, persist. Essential columns are present by default at small screen sizes, but we’ll still be able to toggle their visibility with the custom menu. Marking the Company column with this class provides a way for us to omit it from the menu, and prevent it from being hidden.

We’ll complete the table with rows of data that correspond to the column headers:

<table cellspacing="0" id="tech-companies">
<thead>
   ...
</thead>
<tbody>
   <tr>
      <th>GOOG <span class="co-name">Google Inc.</span></th>
      <td>597.74</td>
      <td>12:12PM</td>
      <td>14.81 (2.54%)</td>
      <td>582.93</td>
      <td>597.95</td>
      <td>597.73 x 100</td>
      <td>597.91 x 300</td>
      <td>731.10</td>
   </tr>
   ...
</tbody>

Later when we apply JavaScript, we’ll create a custom menu based on the table’s content and append it to the page, immediately above the table. The menu will consist of a container element for a “Display” button and the menu overlay:

<div class="table-menu-wrapper">
   <a href="#" class="table-menu-btn">Display</a>
   <div class="table-menu">
      ...menu content...
   </div>
</div>

The menu overlay will contain a list of options, one for each column, where each option has a label and checkbox input for toggling that column’s visibility (columns with the persist class will be excluded from the menu):

<div class="table-menu-wrapper">
   <a href="#" class="table-menu-btn">Display</a>
   <div class="table-menu">
      <ul>
         <li>
            <input type="checkbox" name="toggle-cols" id="toggle-col-1" value="co-1">
            <label for="toggle-col-1">Last Trade</label>
         </li>
         <li class="optional">
            <input type="checkbox" name="toggle-cols" id="toggle-col-2" value="co-2">
            <label for="toggle-col-2">Trade Time</label>
         </li>
         ...
      </ul>
   </div>
</div>

When the script builds the menu, it will automatically assign name and value attributes and unique IDs to the input elements, and matching for attributes to their labels.

Last but not least, we’ll wrap the table in a container element to simplify positioning the menu:

<div class="table-wrapper">
   <table cellspacing="0" id="tech-companies">
      ...
   </table>
</div>

CSS

Permalink to 'CSS'

We’ll assume you’re already familiar with using CSS3 media queries to render pages responsively. If not, we highly recommend Ethan Marcotte’s definitive article, Responsive Web Design, and book by the same title.

Let’s start with the table. We want it to fill the available space, so we’ll assign a width of 100%:

table {
   width: 100%;
   font-size: 1.2em;
}

And then apply color, padding, and alignment properties to make the data easier to scan:

thead th {
   white-space: nowrap;
   border-bottom: 1px solid #ccc;
   color: #888;
}
th, td {
   padding: .5em 1em;
   text-align: right;
}
th:first-child,
td:first-child {
   text-align: left;
}
tbody th, td {
   border-bottom: 1px solid #e6e6e6;
}

Next, we’ll write the rules that hide and show columns. We’ve scoped these styles to a class, enhanced, which is assigned to the table via JavaScript. This ensures that column visibility is altered only when JavaScript is available. By default, we’ll hide all columns, and show only those marked with the essential class:

.enhanced th,
.enhanced td {
   display: none;
}
.enhanced th.essential,
.enhanced td.essential {
   display: table-cell;
}

Using CSS3 media queries, we’ll show optional columns when the browser is 500px wide or greater, and all columns at 800px or greater:

@media screen and (min-width: 500px) {
   .enhanced th.optional,
   .enhanced td.optional {
      display: table-cell;
   }
}

@media screen and (min-width: 800px) {
   .enhanced th,
   .enhanced td {
      display: table-cell;
   }
}

When using this approach, how you prioritize and categorize your data must correspond to the number of screen size breakpoints you plan to support. In our example, we’ve chosen to have two breakpoints, at 500 and 800 pixels wide, and two levels of importance, essential and optional. If we were to add another break point, say around 400px, we would need to rework our categories to include a third (i.e., primary, secondary, tertiary) so that we can mark each column to be visible at a particular breakpoint.

When the custom menu is inserted into the table’s container, it will appear just above the table on the right:

.table-menu-wrapper {
   position: absolute;
   top: -3em;
   right: 0;
}

The menu itself will also be absolutely positioned, and by default will be hidden with the table-menu-hidden class (later, we’ll write JavaScript to toggle that class when the “Display” button is clicked):

.table-menu {
   position: absolute;
   right: 0;
   left: auto;
   background-color: #fff;
   padding: 10px;
   border: 1px solid #ccc;
   font-size: 1.2em;
   width: 12em;
}
.table-menu-hidden {
   left: -999em;
   right: auto;
}

Finally, we’ll add relative positioning to the table’s container. Later, when we append the menu we can position it without having to calculate location coordinates:

.table-wrapper {
   position: relative;
   margin: 5em 5%;
}

JavaScript

Permalink to 'JavaScript'

The table we just created is usable on its own; any browser that renders HTML will display it. With a few JavaScript enhancements, we’ll be able to view the table at smaller screen sizes without sacrificing the table structure. (The following examples use jQuery.)

First we’ll append the enhanced class for scoping styles:

// add class for scoping styles - cells should be hidden only when JS is on
table.addClass("enhanced");

We’ll create a container element for the menu, which will come into play a little later in the script:

var container = $('<div class="table-menu table-menu-hidden"><ul /></div>');

Then we’ll enhance the markup with classes and attributes that allow us to control column visibility. We’ll loop through the table headers and assign them unique IDs, then reference those IDs in headers attributes assigned to associated cells. (The headers attribute identifies to which header(s) a cell belongs.) We’ll also copy the classes that we assigned to the column headers — essential and optional — and assign them to the associated columns.

$( "thead th" ).each(function(i){
   var th = $(this),
      id = th.attr("id"),
      classes = th.attr("class");  // essential, optional (or other content identifiers)

   // assign an ID to each header, if none is in the markup
   if (!id) {
      id = ( "col-" ) + i;
      th.attr("id", id);
   };

   // loop through each row to assign a "headers" attribute and any classes (essential, optional) to the matching cell
   // the "headers" attribute value = the header's ID
   $( "tbody tr" ).each(function(){
      var cell = $(this).find("th, td").eq(i);
      cell.attr("headers", id);
      if (classes) { cell.addClass(classes); };
   });
   ...

Next, while still looping through the headers, we’ll create a menu item for each column, except for those marked with the persist class. Each menu item consists of a checkbox and label with the column header text.

   ...
   // create the menu hide/show toggles
   if ( !th.is(".persist") ) {

      // note that each input's value matches the header's ID;
      // later we'll use this value to control the visibility of that header and it's associated cells
      var toggle = $('<li><input type="checkbox" name="toggle-cols" id="toggle-col-'+i+'" value="'+id+'" /> <label for="toggle-col-'+i+'">'+th.text()+'</label></li>');

      // append each toggle to the container
      container.find("ul").append(toggle);

      ...

And then we’ll bind events to each checkbox for controlling that column’s visibility.

      ...

      // assign behavior
      toggle.find("input")

         // when the checkbox is toggled
         .change(function(){
            var input = $(this),
                  val = input.val(),  // this equals the header's ID, i.e. "company"
                  cols = $("#" + val + ", [headers="+ val +"]"); // so we can easily find the matching header (id="company") and cells (headers="company")

            if (input.is(":checked")) { cols.show(); }
            else { cols.hide(); };
         })

         // custom event that sets the checked state for each toggle based on column visibility, which is controlled by @media rules in the CSS
         // called whenever the window is resized or reoriented (mobile)
         .bind("updateCheck", function(){
            if ( th.css("display") ==  "table-cell") {
               $(this).attr("checked", true);
            }
            else {
               $(this).attr("checked", false);
            };
         })

         // call the custom event on load
         .trigger("updateCheck");

   }; // end conditional statement ( !th.is(".persist") )
}); // end headers loop

After closing the headers loop, we’ll bind our custom event to the window’s resize and orientation change events:

// update the inputs' checked status
$(window).bind("orientationchange resize", function(){
   container.find("input").trigger("updateCheck");
});

And, last but not least, append our checkbox menu to the page and bind show/hide menu events:

var menuWrapper = $('<div class="table-menu-wrapper" />'),
   menuBtn = $('<a href="#" class="table-menu-btn">Display</a>');

menuBtn.click(function(){
   container.toggleClass("table-menu-hidden");
   return false;
});

menuWrapper.append(menuBtn).append(container);
table.before(menuWrapper);  // append the menu immediately before the table

// assign click-away-to-close event
$(document).click(function(e){
   if ( !$(e.target).is( container ) && !$(e.target).is( container.find("*") ) ) {
      container.addClass("table-menu-hidden");
   }
});

Media query support for IE: Respond.js

Permalink to 'Media query support for IE: Respond.js'

Older versions of IE (8 and earlier) don’t natively support CSS3 media queries, so we need to use a workaround in those browsers to implement our responsive table. Thanks to our own Scott Jehl, we can use a lightweight polyfill script, respond.js, that enables support for min- and max-width media query properties. The script is open source and available on github.

Keep the conversation going

Permalink to 'Keep the conversation going'

The pattern discussed here is one possibility for coding a responsive table. We hope to discover more, and will update our RWD-Table-Patterns git repository as we come across additional use cases.

This demo code is open source and available for download. Feel free to put it through its paces. If you use it and see room for improvement, please submit a pull request and we’ll review it as soon as possible.

Permalink to 'Related plug-ins'

We release open source code, including the design pattern here, to encourage collaboration — so we’re especially excited to learn of new plugins that extend our work.

MediaTable, developed by Marco Pegoraro, is a jQuery plugin that applies responsive behavior to one or more standard tables on a page, and also automates the creation of a menu for showing and hiding columns. It’s similar to what we’ve done here, but in “official” plugin format. The code is open source and available on github. Thanks, Marco!

All blog posts