Open demo

CSS code

HTML
<div class="tcard">
  <div class="tscroll" role="region" tabindex="0" aria-label="Purchase order, scrollable">
  <table class="tbl" data-groups>
    <caption class="sr-only">Purchase order grouped by category, with a subtotal per group and a grand total</caption>
    <thead><tr><th scope="col">Item</th><th scope="col">Vendor</th><th scope="col" class="num">Qty</th><th scope="col" class="num">Total</th></tr></thead>
    <tbody class="grp">
      <tr class="head"><th scope="rowgroup"><button type="button" aria-expanded="true"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg> Furniture <span class="count">3<span class="sr-only"> items</span></span></button></th><td></td><td class="num">44</td><td class="num">$28,842.00</td></tr>
      <tr><td>Aeron Chair</td><td>Herman Miller</td><td class="num">12</td><td class="num">$16,740.00</td></tr>
      <tr><td>Standing Desk</td><td>Fully</td><td class="num">8</td><td class="num">$5,992.00</td></tr>
      <tr><td>Monitor Arm</td><td>Ergotron</td><td class="num">24</td><td class="num">$4,536.00</td></tr>
    </tbody>
    <tbody class="grp">
      <tr class="head"><th scope="rowgroup"><button type="button" aria-expanded="true"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg> Hardware <span class="count">2<span class="sr-only"> items</span></span></button></th><td></td><td class="num">30</td><td class="num">$21,470.00</td></tr>
      <tr><td>Studio Display</td><td>Apple</td><td class="num">10</td><td class="num">$15,990.00</td></tr>
      <tr><td>Dock, Thunderbolt</td><td>CalDigit</td><td class="num">20</td><td class="num">$5,480.00</td></tr>
    </tbody>
    <tbody class="grp">
      <tr class="head"><th scope="rowgroup"><button type="button" aria-expanded="false"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg> Software <span class="count">2<span class="sr-only"> items</span></span></button></th><td></td><td class="num">140</td><td class="num">$9,360.00</td></tr>
      <tr hidden><td>Design suite</td><td>Figma</td><td class="num">40</td><td class="num">$7,200.00</td></tr>
      <tr hidden><td>Password manager</td><td>1Password</td><td class="num">100</td><td class="num">$2,160.00</td></tr>
    </tbody>
    <tfoot><tr><th scope="row">Grand total</th><td></td><td class="num">214</td><td class="num">$59,672.00</td></tr></tfoot>
  </table>
  </div>
</div>
CSS
/* Grouped rows: one <tbody> per group. The header row's first cell is a row-group header holding a
   button with aria-expanded; collapsed rows carry the hidden attribute. */
.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}
.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;white-space:nowrap}
.tbl thead th{text-align:left;font-size:.74rem;letter-spacing:.06em;text-transform:uppercase;
  color:#5f6778;font-weight:700;padding:13px 16px;background:#fafbfc;border-bottom:1px solid #eceef3}
.tbl td,.tbl tbody th,.tbl tfoot th{padding:13px 16px;border-bottom:1px solid #f2f4f7;color:#3c4048;text-align:left}
.tbl tbody tr:last-child > *{border-bottom:0}
.tbl tbody tr:hover{background:#fafbfd}
.tbl .num{font-variant-numeric:tabular-nums;text-align:right}
.tbl thead th.num{text-align:right}

.grp tr.head > *{background:#f7f8fb;font-weight:700;color:#16181c;cursor:pointer;border-bottom:1px solid #eceef3}
.grp tr.head button{display:flex;align-items:center;gap:9px;background:none;border:0;padding:0;
  font:inherit;font-weight:700;color:inherit;cursor:pointer;border-radius:4px}
.grp tr.head button:focus-visible{outline:2px solid #4f46e5;outline-offset:2px}
.grp tr.head svg{display:block;width:13px;height:13px;stroke:#667085;fill:none;stroke-width:2.6;
  transition:transform .2s}
.grp tr.head [aria-expanded=true] svg{transform:rotate(90deg)}
.grp .count{font-size:.75rem;font-weight:600;color:#5f6778;background:#eceff4;
  padding:1px 8px;border-radius:999px}
.grp tr:not(.head) td:first-child{padding-left:38px}
.tbl tfoot > tr > *{padding:14px 16px;font-weight:800;color:#16181c;background:#fafbfc;border-top:2px solid #eceef3;border-bottom:0}
@media(prefers-reduced-motion:reduce){.grp tr.head svg{transition:none}}
JavaScript
// Collapsible row groups. A <tbody> cannot be a <details>, so each group's header row holds a
// button with aria-expanded, and the script sets the hidden attribute on the rest of that
// tbody's rows. Clicking anywhere on the header row does the same as the button.
document.querySelectorAll('[data-groups] tbody').forEach(group => {
  const btn = group.querySelector('button[aria-expanded]');
  if (!btn) return;
  const head = btn.closest('tr');
  btn.addEventListener('click', () => {
    const open = btn.getAttribute('aria-expanded') !== 'true';
    btn.setAttribute('aria-expanded', open);
    [...group.rows].forEach(row => { if (row !== head) row.hidden = !open; });
  });
  head.addEventListener('click', e => { if (!e.target.closest('button')) btn.click(); });
});

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="Purchase order, scrollable">
  <table class="table table-hover align-middle mb-0 tbl" data-groups>
    <caption class="visually-hidden">Purchase order grouped by category, with a subtotal per group and a grand total</caption>
    <thead><tr><th scope="col">Item</th><th scope="col">Vendor</th><th scope="col" class="text-end">Qty</th><th scope="col" class="text-end">Total</th></tr></thead>
    <tbody class="grp">
      <tr class="head"><th scope="rowgroup"><button type="button" aria-expanded="true"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg> Furniture <span class="count rounded-pill">3<span class="visually-hidden"> items</span></span></button></th><td></td><td class="text-end num">44</td><td class="text-end num">$28,842.00</td></tr>
      <tr><td>Aeron Chair</td><td>Herman Miller</td><td class="text-end num">12</td><td class="text-end num">$16,740.00</td></tr>
      <tr><td>Standing Desk</td><td>Fully</td><td class="text-end num">8</td><td class="text-end num">$5,992.00</td></tr>
      <tr><td>Monitor Arm</td><td>Ergotron</td><td class="text-end num">24</td><td class="text-end num">$4,536.00</td></tr>
    </tbody>
    <tbody class="grp">
      <tr class="head"><th scope="rowgroup"><button type="button" aria-expanded="true"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg> Hardware <span class="count rounded-pill">2<span class="visually-hidden"> items</span></span></button></th><td></td><td class="text-end num">30</td><td class="text-end num">$21,470.00</td></tr>
      <tr><td>Studio Display</td><td>Apple</td><td class="text-end num">10</td><td class="text-end num">$15,990.00</td></tr>
      <tr><td>Dock, Thunderbolt</td><td>CalDigit</td><td class="text-end num">20</td><td class="text-end num">$5,480.00</td></tr>
    </tbody>
    <tbody class="grp">
      <tr class="head"><th scope="rowgroup"><button type="button" aria-expanded="false"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg> Software <span class="count rounded-pill">2<span class="visually-hidden"> items</span></span></button></th><td></td><td class="text-end num">140</td><td class="text-end num">$9,360.00</td></tr>
      <tr hidden><td>Design suite</td><td>Figma</td><td class="text-end num">40</td><td class="text-end num">$7,200.00</td></tr>
      <tr hidden><td>Password manager</td><td>1Password</td><td class="text-end num">100</td><td class="text-end num">$2,160.00</td></tr>
    </tbody>
    <tfoot><tr><th scope="row">Grand total</th><td></td><td class="text-end num">214</td><td class="text-end num">$59,672.00</td></tr></tfoot>
  </table>
  </div>
</div>
CSS
/* Bootstrap .card + .table-responsive + .table-hover with one <tbody> per group. Group header rows
   and the <tfoot> are recoloured through --bs-table-bg; the chevron turns from aria-expanded. */
.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}
.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;white-space:nowrap}
.tbl > :not(caption) > * > *{padding:13px 16px}
.tbl > tbody > tr:last-child > *{border-bottom-width:0}
.tbl > thead > tr > th{font-size:.74rem;letter-spacing:.06em;text-transform:uppercase;color:#5f6778;
  --bs-table-bg:#fafbfc;border-bottom-color:#eceef3}
.tbl .num{font-variant-numeric:tabular-nums}
.grp > tr.head > *{--bs-table-bg:#f7f8fb;--bs-table-color:#16181c;font-weight:700;cursor:pointer;border-bottom-color:#eceef3}
.tbl.table-hover > .grp > tr.head:hover > *{--bs-table-bg-state:transparent}
.grp > tr.head > th{border-bottom-width:1px}
.grp tr.head button{display:flex;align-items:center;gap:9px;background:none;border:0;padding:0;
  font-weight:700;color:inherit;border-radius:4px}
.grp tr.head button:focus-visible{outline:2px solid #4f46e5;outline-offset:2px}
.grp tr.head svg{display:block;width:13px;height:13px;stroke:#667085;fill:none;stroke-width:2.6;transition:transform .2s}
.grp tr.head [aria-expanded=true] svg{transform:rotate(90deg)}
.grp .count{font-size:.75rem;font-weight:600;color:#5f6778;background:#eceff4;padding:1px 8px}
.grp > tr:not(.head) > td:first-child{padding-left:38px}
.tbl > tfoot > tr > *{padding:14px 16px;font-weight:800;--bs-table-color:#16181c;--bs-table-bg:#fafbfc;
  border-top:2px solid #eceef3;border-bottom-width:0}
@media(prefers-reduced-motion:reduce){.grp tr.head svg{transition:none}}
JavaScript
// Collapsible row groups. A <tbody> cannot be a <details>, so each group's header row holds a
// button with aria-expanded, and the script sets the hidden attribute on the rest of that
// tbody's rows. Clicking anywhere on the header row does the same as the button.
document.querySelectorAll('[data-groups] tbody').forEach(group => {
  const btn = group.querySelector('button[aria-expanded]');
  if (!btn) return;
  const head = btn.closest('tr');
  btn.addEventListener('click', () => {
    const open = btn.getAttribute('aria-expanded') !== 'true';
    btn.setAttribute('aria-expanded', open);
    [...group.rows].forEach(row => { if (row !== head) row.hidden = !open; });
  });
  head.addEventListener('click', e => { if (!e.target.closest('button')) btn.click(); });
});

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div class="overflow-hidden rounded-[14px] border border-[#e7e9f0] bg-white">
  <div class="overflow-x-auto focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#4f46e5]" role="region" tabindex="0" aria-label="Purchase order, scrollable">
  <table class="w-full border-collapse text-[.89rem] whitespace-nowrap [&_tbody_td]:border-b [&_tbody_td]:border-[#f2f4f7] [&_tbody_td]:px-4 [&_tbody_td]:py-[13px] [&_tbody_td]:text-[#3c4048] [&_tbody_tr:last-child>*]:border-b-0" data-groups>
    <caption class="sr-only">Purchase order grouped by category, with a subtotal per group and a grand total</caption>
    <thead><tr><th scope="col" class="border-b border-[#eceef3] bg-[#fafbfc] px-4 py-[13px] text-[.74rem] font-bold tracking-[.06em] text-[#5f6778] uppercase text-left">Item</th><th scope="col" class="border-b border-[#eceef3] bg-[#fafbfc] px-4 py-[13px] text-[.74rem] font-bold tracking-[.06em] text-[#5f6778] uppercase text-left">Vendor</th><th scope="col" class="border-b border-[#eceef3] bg-[#fafbfc] px-4 py-[13px] text-[.74rem] font-bold tracking-[.06em] text-[#5f6778] uppercase text-right">Qty</th><th scope="col" class="border-b border-[#eceef3] bg-[#fafbfc] px-4 py-[13px] text-[.74rem] font-bold tracking-[.06em] text-[#5f6778] uppercase text-right">Total</th></tr></thead>
    <tbody>
      <tr class="cursor-pointer bg-[#f7f8fb] font-bold text-[#16181c] [&>*]:border-b [&>*]:border-[#eceef3]!"><th scope="rowgroup" class="px-4 py-[13px] text-left"><button type="button" class="group flex cursor-pointer items-center gap-[9px] rounded font-bold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5]" aria-expanded="true"><svg class="size-[13px] fill-none stroke-[#667085] stroke-[2.6] transition-transform duration-200 group-aria-expanded:rotate-90 motion-reduce:transition-none" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg> Furniture <span class="rounded-full bg-[#eceff4] px-2 py-px text-[.75rem] font-semibold text-[#5f6778]">3<span class="sr-only"> items</span></span></button></th><td></td><td class="px-4 py-[13px] text-right tabular-nums">44</td><td class="px-4 py-[13px] text-right tabular-nums">$28,842.00</td></tr>
      <tr class="hover:bg-[#fafbfd]"><td class="pl-[38px]!">Aeron Chair</td><td>Herman Miller</td><td class="text-right tabular-nums">12</td><td class="text-right tabular-nums">$16,740.00</td></tr>
      <tr class="hover:bg-[#fafbfd]"><td class="pl-[38px]!">Standing Desk</td><td>Fully</td><td class="text-right tabular-nums">8</td><td class="text-right tabular-nums">$5,992.00</td></tr>
      <tr class="hover:bg-[#fafbfd]"><td class="pl-[38px]!">Monitor Arm</td><td>Ergotron</td><td class="text-right tabular-nums">24</td><td class="text-right tabular-nums">$4,536.00</td></tr>
    </tbody>
    <tbody>
      <tr class="cursor-pointer bg-[#f7f8fb] font-bold text-[#16181c] [&>*]:border-b [&>*]:border-[#eceef3]!"><th scope="rowgroup" class="px-4 py-[13px] text-left"><button type="button" class="group flex cursor-pointer items-center gap-[9px] rounded font-bold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5]" aria-expanded="true"><svg class="size-[13px] fill-none stroke-[#667085] stroke-[2.6] transition-transform duration-200 group-aria-expanded:rotate-90 motion-reduce:transition-none" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg> Hardware <span class="rounded-full bg-[#eceff4] px-2 py-px text-[.75rem] font-semibold text-[#5f6778]">2<span class="sr-only"> items</span></span></button></th><td></td><td class="px-4 py-[13px] text-right tabular-nums">30</td><td class="px-4 py-[13px] text-right tabular-nums">$21,470.00</td></tr>
      <tr class="hover:bg-[#fafbfd]"><td class="pl-[38px]!">Studio Display</td><td>Apple</td><td class="text-right tabular-nums">10</td><td class="text-right tabular-nums">$15,990.00</td></tr>
      <tr class="hover:bg-[#fafbfd]"><td class="pl-[38px]!">Dock, Thunderbolt</td><td>CalDigit</td><td class="text-right tabular-nums">20</td><td class="text-right tabular-nums">$5,480.00</td></tr>
    </tbody>
    <tbody>
      <tr class="cursor-pointer bg-[#f7f8fb] font-bold text-[#16181c] [&>*]:border-b [&>*]:border-[#eceef3]!"><th scope="rowgroup" class="px-4 py-[13px] text-left"><button type="button" class="group flex cursor-pointer items-center gap-[9px] rounded font-bold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5]" aria-expanded="false"><svg class="size-[13px] fill-none stroke-[#667085] stroke-[2.6] transition-transform duration-200 group-aria-expanded:rotate-90 motion-reduce:transition-none" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg> Software <span class="rounded-full bg-[#eceff4] px-2 py-px text-[.75rem] font-semibold text-[#5f6778]">2<span class="sr-only"> items</span></span></button></th><td></td><td class="px-4 py-[13px] text-right tabular-nums">140</td><td class="px-4 py-[13px] text-right tabular-nums">$9,360.00</td></tr>
      <tr hidden class="hover:bg-[#fafbfd]"><td class="pl-[38px]!">Design suite</td><td>Figma</td><td class="text-right tabular-nums">40</td><td class="text-right tabular-nums">$7,200.00</td></tr>
      <tr hidden class="hover:bg-[#fafbfd]"><td class="pl-[38px]!">Password manager</td><td>1Password</td><td class="text-right tabular-nums">100</td><td class="text-right tabular-nums">$2,160.00</td></tr>
    </tbody>
    <tfoot class="[&_td]:border-t-2 [&_td]:border-[#eceef3] [&_td]:bg-[#fafbfc] [&_td]:px-4 [&_td]:py-3.5 [&_td]:font-extrabold [&_th]:border-t-2 [&_th]:border-[#eceef3] [&_th]:bg-[#fafbfc] [&_th]:px-4 [&_th]:py-3.5 [&_th]:text-left [&_th]:font-extrabold"><tr><th scope="row">Grand total</th><td></td><td class="text-right tabular-nums">214</td><td class="text-right tabular-nums">$59,672.00</td></tr></tfoot>
  </table>
  </div>
</div>
JavaScript
// Collapsible row groups. A <tbody> cannot be a <details>, so each group's header row holds a
// button with aria-expanded, and the script sets the hidden attribute on the rest of that
// tbody's rows. Clicking anywhere on the header row does the same as the button.
document.querySelectorAll('[data-groups] tbody').forEach(group => {
  const btn = group.querySelector('button[aria-expanded]');
  if (!btn) return;
  const head = btn.closest('tr');
  btn.addEventListener('click', () => {
    const open = btn.getAttribute('aria-expanded') !== 'true';
    btn.setAttribute('aria-expanded', open);
    [...group.rows].forEach(row => { if (row !== head) row.hidden = !open; });
  });
  head.addEventListener('click', e => { if (!e.target.closest('button')) btn.click(); });
});

About this set

A purchase order split into Furniture, Hardware and Software, each group with a shaded header row that shows the item count and the group’s quantity and cost subtotals, and a grand total in the table footer. Click a group header, or Tab to it and press Enter or Space, to collapse or expand that group. Each group is its own tbody element, which HTML allows any number of, so the structure lives in the markup instead of in classes. The header row’s first cell is a row group header that holds a button with aria-expanded, and the script sets the hidden attribute on the other rows of the same tbody. The chevron rotates from the aria-expanded value, and clicking anywhere on the header row does the same as the button. Item rows are indented so they read as children of their group, and the footer uses a heavier top border and bold figures. Use it for orders, budgets, timesheets and inventory grouped by category. The Software group starts collapsed to show both states.

What’s included

  • One tbody per group, grand total in tfoot
  • Group headers are buttons with aria-expanded
  • Collapsed rows use the hidden attribute
  • Subtotals and an item count on every group header
  • Chevron turn that stops under reduced motion

Accessibility

The caption describes the grouping, column headers use scope="col", group titles are th elements with scope="rowgroup" and the grand total label uses scope="row". Each group toggle is a native button with aria-expanded and a visible 2px focus outline, and the item count includes hidden text so it is read as 3 items. The chevron rotation is removed under prefers-reduced-motion.

Customise it

Group header shading is #f7f8fb on .grp tr.head (plain CSS), --bs-table-bg on the head rows (Bootstrap) or bg-[#f7f8fb] on the head rows (Tailwind). The child indent is the 38px left padding on the first cell of item rows.