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.
Related plug-ins
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!