Open demo

CSS code

HTML
<div class="tcard">
  <div class="tscroll" role="region" tabindex="0" aria-label="Team members, scrollable">
  <table class="tbl" data-sortable>
    <caption class="sr-only">Team members. Column headers are buttons that sort the table.</caption>
    <thead><tr>
      <th scope="col" aria-sort="ascending"><button type="button">Name <span class="arrows" aria-hidden="true"><i></i><i></i></span></button></th>
      <th scope="col"><button type="button">Role <span class="arrows" aria-hidden="true"><i></i><i></i></span></button></th>
      <th scope="col" class="num"><button type="button">Commits <span class="arrows" aria-hidden="true"><i></i><i></i></span></button></th>
      <th scope="col" class="num"><button type="button">Last active <span class="arrows" aria-hidden="true"><i></i><i></i></span></button></th>
    </tr></thead>
    <tbody>
      <tr><td><span class="who"><span class="av" style="background:#4f46e5" aria-hidden="true">AK</span> Aigars K.</span></td><td>Maintainer</td><td class="num">1,284</td><td class="num"><time datetime="2026-09-25T08:00">2 hours ago</time></td></tr>
      <tr><td><span class="who"><span class="av" style="background:#15803d" aria-hidden="true">PN</span> Priya N.</span></td><td>Reviewer</td><td class="num">1,067</td><td class="num"><time datetime="2026-09-25T04:00">6 hours ago</time></td></tr>
      <tr><td><span class="who"><span class="av" style="background:#0369a1" aria-hidden="true">RM</span> Rosa M.</span></td><td>Reviewer</td><td class="num">942</td><td class="num"><time datetime="2026-09-24T15:00">Yesterday</time></td></tr>
      <tr><td><span class="who"><span class="av" style="background:#be123c" aria-hidden="true">TP</span> Tomas P.</span></td><td>Contributor</td><td class="num">318</td><td class="num"><time datetime="2026-09-21T10:00">4 days ago</time></td></tr>
    </tbody>
  </table>
  </div>
</div>
CSS
/* Sortable headers: each <th> holds a <button>; the script sets aria-sort on the active <th>
   and the arrows are styled from that attribute, so the visual state and the announced state
   can never disagree. */
.tcard{background:#fff;border:1px solid #e7e9f0;border-radius:14px;overflow:hidden}
.tscroll{overflow-x:auto}
.tscroll:focus-visible{outline:2px solid #4f46e5;outline-offset:-2px;border-radius:14px}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.tbl{width:100%;border-collapse:collapse;font-size:.89rem}
.tbl td{padding:13px 16px;border-bottom:1px solid #f2f4f7;color:#3c4048}
.tbl tbody tr:last-child td{border-bottom:0}
.tbl tbody tr:hover{background:#fafbfd}
.tbl .num{font-variant-numeric:tabular-nums;text-align:right}
.tbl .who{display:flex;align-items:center;gap:10px;white-space:nowrap}
.tbl .av{width:28px;height:28px;border-radius:50%;display:grid;place-items:center;color:#fff;
  font-size:.7rem;font-weight:800;flex-shrink:0}

.tbl thead th{padding:0;background:#fafbfc;border-bottom:1px solid #eceef3;text-align:left}
.tbl thead th button{width:100%;display:flex;align-items:center;gap:7px;background:none;border:0;
  font:inherit;font-size:.74rem;letter-spacing:.06em;text-transform:uppercase;color:#5f6778;
  font-weight:700;padding:13px 16px;cursor:pointer;text-align:left;white-space:nowrap}
.tbl thead th.num button{justify-content:flex-end}
.tbl thead th button:hover{background:#f2f4f8;color:#344054}
.tbl thead th button:focus-visible{outline:2px solid #4f46e5;outline-offset:-2px}
.tbl thead th[aria-sort] button{color:#4f46e5}
.arrows{display:flex;flex-direction:column;gap:1px;flex-shrink:0}
.arrows i{display:block;width:0;height:0;border-left:3.5px solid transparent;
  border-right:3.5px solid transparent}
.arrows i:first-child{border-bottom:4.5px solid #c5cbd6}
.arrows i:last-child{border-top:4.5px solid #c5cbd6}
th[aria-sort=ascending] .arrows i:first-child{border-bottom-color:#4f46e5}
th[aria-sort=descending] .arrows i:last-child{border-top-color:#4f46e5}
JavaScript
// Sortable headers. A header click sorts by that column; clicking it again reverses.
// Cells are compared by <time datetime> when present, as numbers when they hold one
// (thousands separators stripped), otherwise as text. aria-sort is the source of truth.
document.querySelectorAll('[data-sortable]').forEach(table => {
  const tbody = table.tBodies[0];
  const heads = [...table.tHead.rows[0].cells];
  const key = (row, col) => {
    const cell = row.cells[col];
    const time = cell.querySelector('time');
    if (time) return time.dateTime;
    const text = cell.textContent.trim();
    const n = Number(text.replace(/[^0-9.-]/g, ''));
    return /\d/.test(text) && !Number.isNaN(n) ? n : text.toLowerCase();
  };
  heads.forEach((th, col) => {
    th.querySelector('button').addEventListener('click', () => {
      const asc = th.getAttribute('aria-sort') !== 'ascending';
      heads.forEach(h => h.removeAttribute('aria-sort'));
      th.setAttribute('aria-sort', asc ? 'ascending' : 'descending');
      [...tbody.rows]
        .sort((a, b) => {
          const x = key(a, col), y = key(b, col);
          return (x > y ? 1 : x < y ? -1 : 0) * (asc ? 1 : -1);
        })
        .forEach(r => tbody.appendChild(r));
    });
  });
});

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div class="card tcard overflow-hidden">
  <div class="table-responsive" role="region" tabindex="0" aria-label="Team members, scrollable">
  <table class="table table-hover align-middle mb-0 tbl" data-sortable>
    <caption class="visually-hidden">Team members. Column headers are buttons that sort the table.</caption>
    <thead><tr>
      <th scope="col" aria-sort="ascending"><button type="button" class="sort-btn">Name <span class="arrows" aria-hidden="true"><i></i><i></i></span></button></th>
      <th scope="col"><button type="button" class="sort-btn">Role <span class="arrows" aria-hidden="true"><i></i><i></i></span></button></th>
      <th scope="col" class="num"><button type="button" class="sort-btn">Commits <span class="arrows" aria-hidden="true"><i></i><i></i></span></button></th>
      <th scope="col" class="num"><button type="button" class="sort-btn">Last active <span class="arrows" aria-hidden="true"><i></i><i></i></span></button></th>
    </tr></thead>
    <tbody>
      <tr><td><span class="who d-flex align-items-center text-nowrap"><span class="av rounded-circle text-white" style="background:#4f46e5" aria-hidden="true">AK</span> Aigars K.</span></td><td>Maintainer</td><td class="text-end num">1,284</td><td class="text-end num"><time datetime="2026-09-25T08:00">2 hours ago</time></td></tr>
      <tr><td><span class="who d-flex align-items-center text-nowrap"><span class="av rounded-circle text-white" style="background:#15803d" aria-hidden="true">PN</span> Priya N.</span></td><td>Reviewer</td><td class="text-end num">1,067</td><td class="text-end num"><time datetime="2026-09-25T04:00">6 hours ago</time></td></tr>
      <tr><td><span class="who d-flex align-items-center text-nowrap"><span class="av rounded-circle text-white" style="background:#0369a1" aria-hidden="true">RM</span> Rosa M.</span></td><td>Reviewer</td><td class="text-end num">942</td><td class="text-end num"><time datetime="2026-09-24T15:00">Yesterday</time></td></tr>
      <tr><td><span class="who d-flex align-items-center text-nowrap"><span class="av rounded-circle text-white" style="background:#be123c" aria-hidden="true">TP</span> Tomas P.</span></td><td>Contributor</td><td class="text-end num">318</td><td class="text-end num"><time datetime="2026-09-21T10:00">4 days ago</time></td></tr>
    </tbody>
  </table>
  </div>
</div>
CSS
/* Bootstrap .card + .table-responsive + .table-hover, recoloured through the --bs-table-* variables.
   Each <th> holds a <button>; the arrows are styled from the aria-sort attribute the script sets. */
.tcard{--bs-card-border-color:#e7e9f0;--bs-card-border-radius:14px;--bs-card-bg:#fff}
.tcard .table-responsive:focus-visible{outline:2px solid #4f46e5;outline-offset:-2px;border-radius:14px}
.tbl{--bs-table-color:#3c4048;--bs-table-bg:#fff;--bs-table-border-color:#f2f4f7;
  --bs-table-hover-color:#3c4048;--bs-table-hover-bg:#fafbfd;font-size:.89rem}
.tbl > :not(caption) > * > *{padding:13px 16px}
.tbl > tbody > tr:last-child > *{border-bottom-width:0}
.tbl > thead > tr > th{padding:0;background:#fafbfc;border-bottom-color:#eceef3}
.tbl .num{font-variant-numeric:tabular-nums}
.tbl .who{gap:10px}
.tbl .av{width:28px;height:28px;display:grid;place-items:center;font-size:.7rem;font-weight:800;flex-shrink:0}
.sort-btn{width:100%;display:flex;align-items:center;gap:7px;background:none;border:0;
  font-size:.74rem;letter-spacing:.06em;text-transform:uppercase;color:#5f6778;font-weight:700;
  padding:13px 16px;text-align:left;white-space:nowrap}
.num .sort-btn{justify-content:flex-end}
.sort-btn:hover{background:#f2f4f8;color:#344054}
.sort-btn:focus-visible{outline:2px solid #4f46e5;outline-offset:-2px}
th[aria-sort] .sort-btn{color:#4f46e5}
.arrows{display:flex;flex-direction:column;gap:1px;flex-shrink:0}
.arrows i{display:block;width:0;height:0;border-left:3.5px solid transparent;border-right:3.5px solid transparent}
.arrows i:first-child{border-bottom:4.5px solid #c5cbd6}
.arrows i:last-child{border-top:4.5px solid #c5cbd6}
th[aria-sort=ascending] .arrows i:first-child{border-bottom-color:#4f46e5}
th[aria-sort=descending] .arrows i:last-child{border-top-color:#4f46e5}
JavaScript
// Sortable headers. A header click sorts by that column; clicking it again reverses.
// Cells are compared by <time datetime> when present, as numbers when they hold one
// (thousands separators stripped), otherwise as text. aria-sort is the source of truth.
document.querySelectorAll('[data-sortable]').forEach(table => {
  const tbody = table.tBodies[0];
  const heads = [...table.tHead.rows[0].cells];
  const key = (row, col) => {
    const cell = row.cells[col];
    const time = cell.querySelector('time');
    if (time) return time.dateTime;
    const text = cell.textContent.trim();
    const n = Number(text.replace(/[^0-9.-]/g, ''));
    return /\d/.test(text) && !Number.isNaN(n) ? n : text.toLowerCase();
  };
  heads.forEach((th, col) => {
    th.querySelector('button').addEventListener('click', () => {
      const asc = th.getAttribute('aria-sort') !== 'ascending';
      heads.forEach(h => h.removeAttribute('aria-sort'));
      th.setAttribute('aria-sort', asc ? 'ascending' : 'descending');
      [...tbody.rows]
        .sort((a, b) => {
          const x = key(a, col), y = key(b, col);
          return (x > y ? 1 : x < y ? -1 : 0) * (asc ? 1 : -1);
        })
        .forEach(r => tbody.appendChild(r));
    });
  });
});

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div class="overflow-hidden rounded-[14px] border border-[#e7e9f0] bg-white">
  <div class="overflow-x-auto rounded-[14px] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4f46e5]" role="region" tabindex="0" aria-label="Team members, scrollable">
  <table class="w-full border-collapse text-[.89rem] [&_td]:border-b [&_td]:border-[#f2f4f7] [&_td]:px-4 [&_td]:py-[13px] [&_td]:text-[#3c4048] [&_tbody_tr:hover]:bg-[#fafbfd] [&_tbody_tr:last-child_td]:border-b-0" data-sortable>
    <caption class="sr-only">Team members. Column headers are buttons that sort the table.</caption>
    <thead class="[&_th]:border-b [&_th]:border-[#eceef3] [&_th]:bg-[#fafbfc] [&_th]:p-0 [&_th]:text-left">
      <tr>
        <th scope="col" class="group" aria-sort="ascending"><button type="button" class="flex w-full cursor-pointer items-center gap-[7px] px-4 py-[13px] text-left text-[.74rem] font-bold tracking-[.06em] whitespace-nowrap text-[#5f6778] uppercase hover:bg-[#f2f4f8] hover:text-[#344054] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4f46e5] group-[[aria-sort]]:text-[#4f46e5] group-[[aria-sort]]:hover:text-[#4f46e5]">Name <span class="flex shrink-0 flex-col gap-px" aria-hidden="true"><i class="block size-0 border-x-[3.5px] border-b-[4.5px] border-x-transparent border-b-[#c5cbd6] group-aria-[sort=ascending]:border-b-[#4f46e5]"></i><i class="block size-0 border-x-[3.5px] border-t-[4.5px] border-x-transparent border-t-[#c5cbd6] group-aria-[sort=descending]:border-t-[#4f46e5]"></i></span></button></th>
        <th scope="col" class="group"><button type="button" class="flex w-full cursor-pointer items-center gap-[7px] px-4 py-[13px] text-left text-[.74rem] font-bold tracking-[.06em] whitespace-nowrap text-[#5f6778] uppercase hover:bg-[#f2f4f8] hover:text-[#344054] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4f46e5] group-[[aria-sort]]:text-[#4f46e5] group-[[aria-sort]]:hover:text-[#4f46e5]">Role <span class="flex shrink-0 flex-col gap-px" aria-hidden="true"><i class="block size-0 border-x-[3.5px] border-b-[4.5px] border-x-transparent border-b-[#c5cbd6] group-aria-[sort=ascending]:border-b-[#4f46e5]"></i><i class="block size-0 border-x-[3.5px] border-t-[4.5px] border-x-transparent border-t-[#c5cbd6] group-aria-[sort=descending]:border-t-[#4f46e5]"></i></span></button></th>
        <th scope="col" class="group"><button type="button" class="flex w-full cursor-pointer items-center justify-end gap-[7px] px-4 py-[13px] text-left text-[.74rem] font-bold tracking-[.06em] whitespace-nowrap text-[#5f6778] uppercase hover:bg-[#f2f4f8] hover:text-[#344054] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4f46e5] group-[[aria-sort]]:text-[#4f46e5] group-[[aria-sort]]:hover:text-[#4f46e5]">Commits <span class="flex shrink-0 flex-col gap-px" aria-hidden="true"><i class="block size-0 border-x-[3.5px] border-b-[4.5px] border-x-transparent border-b-[#c5cbd6] group-aria-[sort=ascending]:border-b-[#4f46e5]"></i><i class="block size-0 border-x-[3.5px] border-t-[4.5px] border-x-transparent border-t-[#c5cbd6] group-aria-[sort=descending]:border-t-[#4f46e5]"></i></span></button></th>
        <th scope="col" class="group"><button type="button" class="flex w-full cursor-pointer items-center justify-end gap-[7px] px-4 py-[13px] text-left text-[.74rem] font-bold tracking-[.06em] whitespace-nowrap text-[#5f6778] uppercase hover:bg-[#f2f4f8] hover:text-[#344054] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4f46e5] group-[[aria-sort]]:text-[#4f46e5] group-[[aria-sort]]:hover:text-[#4f46e5]">Last active <span class="flex shrink-0 flex-col gap-px" aria-hidden="true"><i class="block size-0 border-x-[3.5px] border-b-[4.5px] border-x-transparent border-b-[#c5cbd6] group-aria-[sort=ascending]:border-b-[#4f46e5]"></i><i class="block size-0 border-x-[3.5px] border-t-[4.5px] border-x-transparent border-t-[#c5cbd6] group-aria-[sort=descending]:border-t-[#4f46e5]"></i></span></button></th>
      </tr>
    </thead>
    <tbody>
      <tr><td><span class="flex items-center gap-2.5 whitespace-nowrap"><span class="grid size-7 shrink-0 place-items-center rounded-full bg-[#4f46e5] text-[.7rem] font-extrabold text-white" aria-hidden="true">AK</span> Aigars K.</span></td><td>Maintainer</td><td class="text-right tabular-nums">1,284</td><td class="text-right tabular-nums"><time datetime="2026-09-25T08:00">2 hours ago</time></td></tr>
      <tr><td><span class="flex items-center gap-2.5 whitespace-nowrap"><span class="grid size-7 shrink-0 place-items-center rounded-full bg-[#15803d] text-[.7rem] font-extrabold text-white" aria-hidden="true">PN</span> Priya N.</span></td><td>Reviewer</td><td class="text-right tabular-nums">1,067</td><td class="text-right tabular-nums"><time datetime="2026-09-25T04:00">6 hours ago</time></td></tr>
      <tr><td><span class="flex items-center gap-2.5 whitespace-nowrap"><span class="grid size-7 shrink-0 place-items-center rounded-full bg-[#0369a1] text-[.7rem] font-extrabold text-white" aria-hidden="true">RM</span> Rosa M.</span></td><td>Reviewer</td><td class="text-right tabular-nums">942</td><td class="text-right tabular-nums"><time datetime="2026-09-24T15:00">Yesterday</time></td></tr>
      <tr><td><span class="flex items-center gap-2.5 whitespace-nowrap"><span class="grid size-7 shrink-0 place-items-center rounded-full bg-[#be123c] text-[.7rem] font-extrabold text-white" aria-hidden="true">TP</span> Tomas P.</span></td><td>Contributor</td><td class="text-right tabular-nums">318</td><td class="text-right tabular-nums"><time datetime="2026-09-21T10:00">4 days ago</time></td></tr>
    </tbody>
  </table>
  </div>
</div>
JavaScript
// Sortable headers. A header click sorts by that column; clicking it again reverses.
// Cells are compared by <time datetime> when present, as numbers when they hold one
// (thousands separators stripped), otherwise as text. aria-sort is the source of truth.
document.querySelectorAll('[data-sortable]').forEach(table => {
  const tbody = table.tBodies[0];
  const heads = [...table.tHead.rows[0].cells];
  const key = (row, col) => {
    const cell = row.cells[col];
    const time = cell.querySelector('time');
    if (time) return time.dateTime;
    const text = cell.textContent.trim();
    const n = Number(text.replace(/[^0-9.-]/g, ''));
    return /\d/.test(text) && !Number.isNaN(n) ? n : text.toLowerCase();
  };
  heads.forEach((th, col) => {
    th.querySelector('button').addEventListener('click', () => {
      const asc = th.getAttribute('aria-sort') !== 'ascending';
      heads.forEach(h => h.removeAttribute('aria-sort'));
      th.setAttribute('aria-sort', asc ? 'ascending' : 'descending');
      [...tbody.rows]
        .sort((a, b) => {
          const x = key(a, col), y = key(b, col);
          return (x > y ? 1 : x < y ? -1 : 0) * (asc ? 1 : -1);
        })
        .forEach(r => tbody.appendChild(r));
    });
  });
});

About this set

A team table whose four column headers sort the rows: name, role, commit count and last active time. Click a header to sort ascending, click it again to reverse, and the other headers return to a neutral state with two grey arrows. Each header cell holds a real button, so sorting works with a mouse, with Tab and Enter or Space, and with a screen reader. The script writes aria-sort as ascending or descending on the active header cell, and the CSS colours the arrows and the header text from that attribute, so what is shown and what is announced cannot drift apart. Values are compared in three ways: a time element is sorted by its datetime attribute, cells that hold a number are compared as numbers with thousands separators removed, and everything else is compared as lowercase text. That keeps 1,284 above 942 and puts 2 hours ago before Yesterday. Use it for any small to medium table that is sorted in the browser; for server-side sorting, keep the markup and aria-sort and replace the array sort with a request. The Bootstrap version is a .table-hover inside a .card and .table-responsive, and the Tailwind version uses group-aria-[sort] variants for the arrows.

What’s included

  • Three header states: none, ascending and descending
  • aria-sort on the active header drives the arrow styling
  • Sorts dates by datetime, numbers numerically and text alphabetically
  • Header buttons work with the keyboard and show a focus ring
  • Scrolls sideways in a labelled, focusable region on narrow screens

Accessibility

The table has a caption, every header is a th with scope="col" and holds a native button, and the active header carries aria-sort. The arrows are aria-hidden because the sort direction is exposed through aria-sort. Header buttons and the scroll region show a 2px indigo outline on keyboard focus. Nothing animates.

Customise it

Change the indigo #4f46e5 used for the active header and arrows and the #c5cbd6 idle arrow colour in the .arrows rules (plain CSS and Bootstrap) or in the group-aria-[sort] classes (Tailwind). Row padding sits on .tbl td, on .tbl > :not(caption) > * > * in Bootstrap, and in the [&_td] utilities in Tailwind.