Table (Responsive, Collapsible)

Table (Responsive, Collapsible)

Tables are notoriously difficult to view on small devices, and difficult for web designers to implement in ways that don't break the layout of the page. This responsive table design displays as a table in screens wide enough to display the table and collapses into a list format when the screen is too small to show the table horizontally.



Turn on a screen reader to experience this example in action.

Example 1

When collapsed, the rows of this table show as nested bulleted lists, organized by a parent list with the word "row" at the beginning of each row. This method is useful as a generic fallback when you're not sure which table cells would work best as labels when the table is collapsed.

Generic "Row" Label When Collapsed
First Name Last Name Age Favorite Color Select
Alice Egghead 33 blue
Bob Smith 43 purple
Charlie Jones 23 yellow
Denise Brown 53 green
Eloise Humperdink 83 red

Example 2

When collapsed, the nested lists are labeled by parent list items that take the text from the first two columns of the table (e.g. "Alice Egghead"). This method has the advantage of giving each list a more human-readable label than simply "row" as in the previous table.

Two-Column Label Function (First Name and Last Name) When Collapsed
First Name Last Name Age Favorite Color Select
Alice Egghead 33 blue
Bob Smith 43 purple
Charlie Jones 23 yellow
Denise Brown 53 green
Eloise Humperdink 83 red

Example 3

When collapsed, the rows turn into a single button. This result isn't applicable to many situations, but it may be appropriate in cases where the main point of the row is to have the user activate a button.

Row Summed Up Into a Button When Collapsed
First Name Last Name Age Favorite Color Select
Alice Egghead 33 blue
Bob Smith 43 purple
Charlie Jones 23 yellow
Denise Brown 53 green
Eloise Humperdink 83 red

HTML Source Code

<h2>Example 1</h2>
        <p>When collapsed, the rows of this table show as nested bulleted lists, organized by a parent list with the word "row" at the beginning of each row. This method is useful as a generic fallback when you're not sure which table cells would work best as labels when the table is collapsed.</p>
        <table class="deque-table-responsive" id="table1" border="1">
            <caption>
            Generic &quot;Row&quot; Label When Collapsed
            </caption>
            <thead>
              <tr role="row">
                <th scope="col" role="columnheader">First Name</th>
                <th scope="col" role="columnheader">Last Name</th>
                <th scope="col" role="columnheader">Age</th>
                <th scope="col" role="columnheader">Favorite Color</th>
                <th scope="col" role="columnheader">Select</th>
              </tr>
            </thead>
            <tbody>
              <tr role="row">
                <th scope="row" role="rowheader">Alice</th>
                <td>Egghead</td>
                <td>33</td>
                <td>blue</td>
                <td>
                  <button id="select_1a" aria-label="Select Alice">Select</button>
                </td>
              </tr>
              <tr role="row">
                <th scope="row" role="rowheader">Bob</th>
                <td>Smith</td>
                <td>43</td>
                <td>purple</td>
                <td>
                  <button id="select_1b" aria-label="Select Bob">Select</button>
                </td>
              </tr>
              <tr role="row">
                <th scope="row" role="rowheader">Charlie</th>
                <td>Jones</td>
                <td>23</td>
                <td>yellow</td>
                <td>
                  <button id="select_1c" aria-label="Select Charlie">Select</button>
                </td>
              </tr>
              <tr role="row">
                <th scope="row" role="rowheader">Denise</th>
                <td>Brown</td>
                <td>53</td>
                <td>green</td>
                <td>
                  <button id="select_1d" aria-label="Select Denise">Select</button>
                </td>
              </tr>
              <tr role="row">
                <th scope="row" role="rowheader">Eloise</th>
                <td>Humperdink</td>
                <td>83</td>
                <td>red</td>
                <td>
                  <button id="select_1e" aria-label="Select Eloise">Select</button>
                </td>
              </tr>
            </tbody>
          </table>

          <h2>Example 2</h2>
          <p>When collapsed, the nested lists are labeled by parent list items that take the text from the first two columns of the table (e.g. "Alice Egghead"). This method has the advantage of giving each list a more human-readable label than simply "row" as in the previous table.</p>
          <table class="deque-table-responsive" id="table2" border="1">
            <caption>
            Two-Column Label Function (First Name and Last Name) When Collapsed
            </caption>
            <thead>
              <tr role="row">
                <th scope="col" role="columnheader">First Name</th>
                <th scope="col" role="columnheader">Last Name</th>
                <th scope="col" role="columnheader">Age</th>
                <th scope="col" role="columnheader">Favorite Color</th>
                <th scope="col" role="columnheader">Select</th>
              </tr>
            </thead>
            <tbody>
              <tr role="row">
                <th scope="row" role="rowheader">Alice</th>
                <td>Egghead</td>
                <td>33</td>
                <td>blue</td>
                <td>
                  <button id="select_2a" aria-label="Select Alice">Select</button>
                </td>
              </tr>
              <tr role="row">
                <th scope="row" role="rowheader">Bob</th>
                <td>Smith</td>
                <td>43</td>
                <td>purple</td>
                <td>
                  <button id="select_2b" aria-label="Select Bob">Select</button>
                </td>
              </tr>
              <tr role="row">
                <th scope="row" role="rowheader">Charlie</th>
                <td>Jones</td>
                <td>23</td>
                <td>yellow</td>
                <td>
                  <button id="select_2c" aria-label="Select Charlie">Select</button>
                </td>
              </tr>
              <tr role="row">
                <th scope="row" role="rowheader">Denise</th>
                <td>Brown</td>
                <td>53</td>
                <td>green</td>
                <td>
                  <button id="select_2d" aria-label="Select Denise">Select</button>
                </td>
              </tr>
              <tr role="row">
                <th scope="row" role="rowheader">Eloise</th>
                <td>Humperdink</td>
                <td>83</td>
                <td>red</td>
                <td>
                  <button id="select_2e" aria-label="Select Eloise">Select</button>
                </td>
              </tr>
            </tbody>
          </table>

          <h2>Example 3</h2>
          <p>When collapsed, the rows turn into a single button. This result isn't applicable to many situations, but it may be appropriate in cases where the main point of the row is to have the user activate a button.</p>
          <table class="deque-table-responsive" id="table3" border="1">
            <caption>
            Row Summed Up Into a Button When Collapsed
            </caption>
            <thead>
                <tr role="row">
                <th scope="col" role="columnheader">First Name</th>
                <th scope="col" role="columnheader">Last Name</th>
                <th scope="col" role="columnheader">Age</th>
                <th scope="col" role="columnheader">Favorite Color</th>
                <th scope="col" role="columnheader">Select</th>
                </tr>
            </thead>
            <tbody>
                <tr role="row">
                <th scope="row" role="rowheader">Alice</th>
                <td>Egghead</td>
                <td>33</td>
                <td>blue</td>
                <td>
                    <button id="select_3a" aria-label="Select Alice">Select</button>
                </td>
                </tr>
                <tr role="row">
                <th scope="row" role="rowheader">Bob</th>
                <td>Smith</td>
                <td>43</td>
                <td>purple</td>
                <td>
                    <button id="select_3b" aria-label="Select Bob">Select</button>
                </td>
                </tr>
                <tr role="row">
                <th scope="row" role="rowheader">Charlie</th>
                <td>Jones</td>
                <td>23</td>
                <td>yellow</td>
                <td>
                    <button id="select_3c" aria-label="Select Charlie">Select</button>
                </td>
                </tr>
                <tr role="row">
                <th scope="row" role="rowheader">Denise</th>
                <td>Brown</td>
                <td>53</td>
                <td>green</td>
                <td>
                    <button id="select_3d" aria-label="Select Denise">Select</button>
                </td>
                </tr>
                <tr role="row">
                <th scope="row" role="rowheader">Eloise</th>
                <td>Humperdink</td>
                <td>83</td>
                <td>red</td>
                <td>
                    <button id="select_3e" aria-label="Select Eloise">Select</button>
                </td>
                </tr>
            </tbody>
        </table>

JavaScript Source Code

function toArray(arraylike) {
    return Array.prototype.slice.call(arraylike);
  }   
  
  function queryAll(selector, context) {
    context = context || document;
    return toArray(context.querySelectorAll(selector));
  }    
         
         function getTableName(table) {
    const caption = table.querySelector('caption');
    if (caption) {
      return caption.innerText;
    }
  
    return table.getAttribute('aria-label') || 'unnamed table';
  }
  
  function makeTableResponsive(table, labelDetails = {}, inflectionPoint = 300) {
    var resizeTimeout;
  
    function resizeThrottler() {
      // ignore resize events as long as an handleResponsiveLogic execution is in the queue
      if (!resizeTimeout) {
        resizeTimeout = setTimeout(function() {
          resizeTimeout = null;
          handleResponsiveLogic();
  
          // The handleResponsiveLogic will execute at a rate of 15fps
        }, 66);
      }
    }
  
    function handleResponsiveLogic() {
      // handle the resize event
      if (window.innerWidth < inflectionPoint) {
        renderAsList();
      } else {
        renderAsTable();
      }
    }
  
    let list;
    let wrapper = document.createElement('div');
    wrapper.classList.add('responsive-table-wrapper');
  
    let notificationRegion = document.createElement('div');
    notificationRegion.setAttribute('role', 'alert');
    notificationRegion.setAttribute('aria-live', 'polite');
    notificationRegion.classList.add('visuallyhidden');
    wrapper.appendChild(notificationRegion);
  
    function notify(message) {
      notificationRegion.innerText = message;
    }
  
    let tableParent = table.parentElement;
  
    tableParent.insertBefore(wrapper, table);
    wrapper.appendChild(table);
  
    function renderAsList() {
      if (!list) {
        let focusID = document.activeElement.id;
        list = collapseTableToList(table, labelDetails);
        wrapper.appendChild(list);
        wrapper.removeChild(table);
        notify('The data for ' + getTableName(table) + ' is now being rendered as a list.');
        if (focusID) {
          let focusTarget = document.querySelector(`#${focusID}`);
          if (focusTarget) {
            focusTarget.focus();
          }
        }
      }
    }
  
    function renderAsTable() {
      if (list) {
        let focusID = document.activeElement.id;
        wrapper.removeChild(list);
        wrapper.appendChild(table);
        list = null;
        notify('The data for ' + getTableName(table) + ' is now being rendered as a table.');
  
        if (focusID) {
          let focusTarget = document.querySelector(`#${focusID}`);
          if (focusTarget) {
            focusTarget.focus();
          }
        }
      }
    }
  
    window.addEventListener('resize', resizeThrottler);
    handleResponsiveLogic();
    return table;
  }
  
  function collapseTableToList(table, {labelColumns = [], labelFunction = () => 'Row:'}) {
    let output = document.createElement('div');
    output.classList.add('deque');
    output.classList.add('responsive-table-list-holder');
  
    const caption = document.createElement('h3');
    caption.innerText = getTableName(table);
    output.appendChild(caption);
  
    let headers = queryAll('th', table);
    let rows = queryAll('tbody tr', table);
  
    let list = document.createElement('ul');
    list.classList.add('collapsed-table');
    list.setAttribute('data-num-columns', headers.length);
  
    rows.reduce((list, row) => {
      let labelData = [];
      labelColumns.forEach(i => {
        labelData.push(row.children[i]);
      });
  
      let header = labelFunction.apply(null, labelData);
      let li = document.createElement('li');
      let label = document.createElement('span');
      label.classList.add('collapsed-table-header');
      label.innerHTML = header.outerHTML ? header.outerHTML : header;
      li.appendChild(label);
  
      let sublist = document.createElement('ul');
      sublist.classList.add('collapsed-table-content');
  
      queryAll('th, td', row).forEach((cell, index) => {
        let contentColumns = [];
        if (labelColumns.indexOf(index) === -1) {
          contentColumns.push({cell, label: headers[index].innerHTML});
        }
  
        if (contentColumns.length > 0) {
  
          contentColumns.forEach(datum => {
            let item = document.createElement('li');
  
            item.setAttribute('data-table-column-index', index);
  
            let labelSpan = document.createElement('span');
            labelSpan.innerHTML = datum.label;
            item.appendChild(labelSpan);
  
            let content = document.createElement('div');
            content.innerHTML = datum.cell.innerHTML;
            item.appendChild(content);
            sublist.appendChild(item);
          });
        }
        // preserves focus
  
        list.appendChild(li);
      });
  
      if (sublist.children.length > 0) {
        li.appendChild(sublist);
      }
  
      return list;
    }, list);
  
    output.appendChild(list);
    return output;
  }
  /* end @table-collapsing */
  
  var table1 = document.querySelector('#table1');
  var table2 = document.querySelector('#table2');
  var table3 = document.querySelector('#table3');
  
  var partialLabelDetails = {
    labelColumns: [0, 1],
    labelFunction: function(firstName, lastName) {
      return firstName.innerText + ' ' + lastName.innerText;
    }
  };
  
  var totalLabelDetails = {
    labelColumns: [0, 1, 2, 3, 4],
    labelFunction: function(firstName, lastName, age, favoriteColor, summary) {
      var label = firstName.innerText + ' ' + lastName.innerText + ' is ' + age.innerText + ' years old and likes the color ' + favoriteColor.innerText;
      let sourceButton = summary.querySelector('button');
      let button = document.createElement('button');
      button.setAttribute('aria-label', 'select ' + label);
      button.id = sourceButton.id;
      button.innerText = label;
  
      // note we are returning a new button - if you start mutating
      // the original button those mutations would carry over to the
      // table view, too. Best not to do that, but YMMV.
      return button;
    }
  };
  
  makeTableResponsive(table1, {}, 500);
  makeTableResponsive(table2, partialLabelDetails, 500);
  makeTableResponsive(table3, totalLabelDetails, 500);
  

CSS Source Code

.responsive-table-wrapper {

    table {
      text-align: left;
      border-collapse: collapse;
      margin: 0 0 40px;
    
      caption {
        text-align: left;
        font-size: 1.25rem;
        margin: 20px 0;
      }
    
      thead {
    
        th {
          padding: 6px;
          text-align: left;
        }
    
        tr {
          border-top: 1px solid #5e5e5e;
          border-bottom: 1px solid #5e5e5e;
          height: 50px;
        }
      }
    
      tbody {
    
        tr {
    
          &:last-child {
            border-bottom: 1px solid #5e5e5e;
          }
    
          button {
            margin: 8px;
            outline-offset: 1.5px;
    
            &:focus,
            &:hover {
              outline: 2px solid #467310 !important;
              border: 1px #333 solid !important;
            }
          }
    
          td,
          th {
            padding: 6px;
          }
    
          &:nth-child(odd) {
            background: #f4f4f4;;
          }
        }
      }
    }
    }
    
    

Copy and Paste Full Page Example