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

<div class="dqu-example">
<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>
</div>

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();
    table.destroy = function() {
      window.removeEventListener('resize', resizeThrottler);
      if (resizeTimeout) {
        clearTimeout(resizeTimeout);
        resizeTimeout = null;
      }
    };
    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

/*
  Table (Responsive) — Restyled
  Deque University ARIA Component
*/

:root {
  --dqu-interactive: #2e5f7a;
  --dqu-interactive-hover: #3a7a9a;
  --dqu-interactive-light: rgba(46, 95, 122, 0.08);
  --dqu-bg-primary: #fcfaf8;
  --dqu-bg-secondary: #f6f3ed;
  --dqu-border-secondary: #8c827d;
  --dqu-text-primary: #21201e;
  --dqu-font-family: "Noto Sans", sans-serif;
}

.responsive-table-wrapper {

  table {
    text-align: left;
    border-collapse: collapse;
    margin: 0 0 40px;
    font-family: var(--dqu-font-family);
    font-size: 0.9375rem;
    color: var(--dqu-text-primary);

    caption {
      text-align: left;
      font-size: 1rem;
      font-weight: 600;
      margin: 20px 0;
      color: var(--dqu-text-primary);
    }

    thead {

      th {
        padding: 10px 12px;
        text-align: left;
        font-weight: 600;
        background-color: var(--dqu-interactive);
        color: #ffffff;
        border: 1px solid var(--dqu-border-secondary);
        border-color: rgba(255, 255, 255, 0.3);
        border-top-color: var(--dqu-border-secondary);
        border-bottom-color: var(--dqu-border-secondary);
      }

      th:first-child {
        border-left-color: var(--dqu-border-secondary);
      }

      th:last-child {
        border-right-color: var(--dqu-border-secondary);
      }

      tr {
        height: auto;
      }
    }

    tbody {

      tr {

        &:last-child {
          border-bottom: 1px solid var(--dqu-border-secondary);
        }

        button {
          margin: 4px;
          padding: 6px 12px;
          font-family: var(--dqu-font-family);
          font-size: 0.8125rem;
          font-weight: 600;
          border: 1px solid var(--dqu-interactive);
          border-radius: 9999px;
          background: #ffffff;
          color: var(--dqu-interactive);
          cursor: pointer;
          outline-offset: 2px;

          &:focus,
          &:hover {
            outline: 2px solid var(--dqu-interactive) !important;
            border: 1px solid var(--dqu-interactive) !important;
            background-color: var(--dqu-interactive-light);
          }
        }

        td,
        th {
          padding: 10px 12px;
          border: 1px solid var(--dqu-border-secondary);
        }

        &:nth-child(odd) {
          background: var(--dqu-bg-secondary);
        }

        &:nth-child(even) {
          background: #ffffff;
        }
      }
    }
  }
}

/* Collapsed list view (narrow screen) — minimal changes */
.responsive-table-list-holder {
  font-family: var(--dqu-font-family);
}

.responsive-table-list-holder li {
  margin-bottom: 6px;
}

/* Example 3 — when a row collapses to a single button, hide the
   list bullet so the button reads as a discrete clickable item.
   Targets only the top-level li whose header span contains a button
   (i.e., the "row summed up into a button" case in Example 3).
   Examples 1 and 2 keep their bullets. */
.collapsed-table > li:has(> .collapsed-table-header > button) {
  list-style: none;
}

.collapsed-table:has(> li > .collapsed-table-header > button) {
  padding-left: 0;
}

/* Example 3 row-summed-up-to-button is the primary action for the row.
   Bump its font-size and padding so it reads as a substantial button,
   not a small Select button. (Higher specificity selector to win
   against the later .responsive-table-list-holder button rule.) */
.responsive-table-list-holder .collapsed-table-header > button {
  font-size: 0.9375rem;
  padding: 10px 16px;
}

.responsive-table-list-holder button {
  margin-top: 4px;
  padding: 6px 12px;
  font-family: var(--dqu-font-family);
  font-size: 0.8125rem;
  font-weight: 600;
  border: 1px solid var(--dqu-interactive);
  border-radius: 9999px;
  background: #ffffff;
  color: var(--dqu-interactive);
  cursor: pointer;
}

.responsive-table-list-holder button:focus,
.responsive-table-list-holder button:hover {
  outline: 2px solid var(--dqu-interactive) !important;
  outline-offset: 2px;
  background-color: var(--dqu-interactive-light);
}

.dqu-example h2 {
  font-family: var(--dqu-font-family);
  color: var(--dqu-text-primary);
}

.dqu-example p {
  font-family: var(--dqu-font-family);
  font-size: 0.9375rem;
  line-height: 1.5;
  color: var(--dqu-text-primary);
}

/* Prevent wide table content from expanding the layout viewport past the
   500px inflection point, which would suppress the mobile list collapse.
   German/Dutch column headers push the table's min-width above 500px;
   this containment keeps window.innerWidth at the device width instead. */
.dqu-example {
  overflow-x: auto;
}

/* Live-region notification div added by makeTableResponsive() */
.visuallyhidden {
  border: 0;
  clip: rect(0 0 0 0);
  height: 1px;
  margin: -1px;
  overflow: hidden;
  padding: 0;
  position: absolute;
  white-space: nowrap;
  width: 1px;
}

Copy and Paste Full Page Example