<h2 class="xp-title" id="xp-title">Expense claims</h2>
<p class="xp-sub">Click a row to expand its detail line — an accessible accordion inside a table.</p>
<div class="xp-card" role="region" tabindex="0" aria-label="Expense claims, scrollable">
<table class="xp" aria-labelledby="xp-title">
<thead><tr><th scope="col">Claim</th><th scope="col">Employee</th><th scope="col">Amount</th><th scope="col">Date</th></tr></thead>
<tbody>
<tr class="xp-row"><th scope="row"><button class="xp-toggle" type="button" aria-expanded="false" aria-controls="xp-311"><svg class="xp-chev" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 0l7 5-7 5z"/></svg>EXP-311</button></th><td>Sofia Marino</td><td class="xp-amount">$482.90</td><td>Jun 27</td></tr>
<tr class="xp-detail" id="xp-311"><td colspan="4"><div class="xp-inner"><div><p><b>Client visit, Milan.</b> Train fare $198.00 · Hotel one night $214.90 · Meals $70.00. Approved by L. Farrell. Receipts attached (3).</p></div></div></td></tr>
<tr class="xp-row"><th scope="row"><button class="xp-toggle" type="button" aria-expanded="false" aria-controls="xp-310"><svg class="xp-chev" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 0l7 5-7 5z"/></svg>EXP-310</button></th><td>Ben Okafor</td><td class="xp-amount">$96.20</td><td>Jun 25</td></tr>
<tr class="xp-detail" id="xp-310"><td colspan="4"><div class="xp-inner"><div><p><b>Team offsite supplies.</b> Whiteboard markers, sticky notes, and adapters from OfficeMart. Card ending 4417. Receipts attached (1).</p></div></div></td></tr>
<tr class="xp-row"><th scope="row"><button class="xp-toggle" type="button" aria-expanded="false" aria-controls="xp-309"><svg class="xp-chev" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 0l7 5-7 5z"/></svg>EXP-309</button></th><td>Clara Nilsen</td><td class="xp-amount">$1,240.00</td><td>Jun 22</td></tr>
<tr class="xp-detail" id="xp-309"><td colspan="4"><div class="xp-inner"><div><p><b>Conference ticket, WebSummit.</b> Early-bird pass plus workshop add-on. Pending manager approval — flagged over the $1,000 single-item limit.</p></div></div></td></tr>
<tr class="xp-row"><th scope="row"><button class="xp-toggle" type="button" aria-expanded="false" aria-controls="xp-308"><svg class="xp-chev" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 0l7 5-7 5z"/></svg>EXP-308</button></th><td>Igor Petrov</td><td class="xp-amount">$58.75</td><td>Jun 20</td></tr>
<tr class="xp-detail" id="xp-308"><td colspan="4"><div class="xp-inner"><div><p><b>Client lunch.</b> Two covers at Verde. Project code NW-114. Approved automatically under the $75 meal threshold.</p></div></div></td></tr>
</tbody>
</table>
</div>
CSS
/* Expandable rows: each claim number is a button with aria-expanded that opens the detail row below it.
Clicking anywhere on the row does the same. The detail animates open with grid-template-rows 0fr -> 1fr,
and stays visibility:hidden while closed so screen readers skip it. The card is a scroll region for narrow screens. */
.xp-title{font-size:1.15rem;font-weight:700;letter-spacing:-.01em;margin:0 0 4px}
.xp-sub{color:#6f6353;font-size:.87rem;margin:0 0 20px}
.xp-card{overflow-x:auto;background:#fff;border:1px solid #ece5da;border-radius:14px;box-shadow:0 12px 28px -18px rgba(74,58,36,.18)}
.xp-card:focus-visible{outline:2px solid #b45309;outline-offset:2px}
.xp{width:100%;border-collapse:collapse;font-size:.9rem}
.xp th,.xp td{text-align:left}
.xp thead th{white-space:nowrap;font-size:.73rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:#6f6353;padding:12px 18px;background:#faf6f0;border-bottom:1px solid #ece5da}
.xp tbody th,.xp td{padding:13px 18px;border-top:1px solid #f3eee6;color:#4a3d2e}
.xp-row{cursor:pointer;transition:background-color .12s}
.xp-row:hover{background:#fbf7f1}
.xp-row th{font-weight:600}
.xp-toggle{display:inline-flex;white-space:nowrap;align-items:center;gap:10px;padding:0;border:0;background:none;font:inherit;font-weight:600;color:#2a2118;cursor:pointer;border-radius:4px}
.xp-toggle:focus-visible{outline:2px solid #b45309;outline-offset:3px}
.xp-chev{width:10px;height:10px;fill:#9c8b72;transition:transform .2s ease}
.xp-toggle[aria-expanded="true"] .xp-chev{transform:rotate(90deg)}
.xp-amount{font-variant-numeric:tabular-nums;font-weight:600}
.xp-detail>td{padding:0;border-top:0}
.xp-inner{display:grid;grid-template-rows:0fr;visibility:hidden;transition:grid-template-rows .25s ease,visibility .25s}
.xp-detail.open .xp-inner{grid-template-rows:1fr;visibility:visible}
.xp-inner>div{overflow:hidden;min-height:0}
.xp-inner p{margin:0;padding:16px 18px 18px 46px;background:#faf6ef;border-top:1px dashed #e7ddcd;font-size:.85rem;line-height:1.65;color:#6d5e4b}
.xp-inner b{color:#2a2118}
@media (max-width:480px){.xp thead th,.xp tbody th,.xp td{padding-left:10px;padding-right:10px}.xp .xp-detail>td{padding:0}.xp-inner p{padding-left:30px;padding-right:10px}}
@media (prefers-reduced-motion:reduce){.xp-row,.xp-chev,.xp-inner{transition:none}}
JavaScript
// Each toggle button opens the detail row named in its aria-controls; a click anywhere else on the row presses the button.
document.querySelectorAll('.xp-toggle').forEach(function (btn) {
var row = btn.closest('tr');
var detail = document.getElementById(btn.getAttribute('aria-controls'));
if (!row || !detail) return;
btn.addEventListener('click', function () {
var open = btn.getAttribute('aria-expanded') !== 'true';
btn.setAttribute('aria-expanded', String(open));
detail.classList.toggle('open', open);
});
row.addEventListener('click', function (e) {
if (!e.target.closest('button, a, input')) btn.click();
});
});
<h2 class="xp-title" id="xp-title">Expense claims</h2>
<p class="xp-sub">Click a row to expand its detail line — an accessible accordion inside a table.</p>
<div class="xp-card table-responsive" role="region" tabindex="0" aria-label="Expense claims, scrollable">
<table class="table xp align-middle mb-0" aria-labelledby="xp-title">
<thead><tr><th scope="col">Claim</th><th scope="col">Employee</th><th scope="col">Amount</th><th scope="col">Date</th></tr></thead>
<tbody>
<tr class="xp-row"><th scope="row"><button class="xp-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#xp-311" aria-expanded="false" aria-controls="xp-311"><svg class="xp-chev" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 0l7 5-7 5z"/></svg>EXP-311</button></th><td>Sofia Marino</td><td class="xp-amount">$482.90</td><td>Jun 27</td></tr>
<tr class="xp-detail"><td colspan="4"><div class="collapse" id="xp-311"><p><b>Client visit, Milan.</b> Train fare $198.00 · Hotel one night $214.90 · Meals $70.00. Approved by L. Farrell. Receipts attached (3).</p></div></td></tr>
<tr class="xp-row"><th scope="row"><button class="xp-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#xp-310" aria-expanded="false" aria-controls="xp-310"><svg class="xp-chev" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 0l7 5-7 5z"/></svg>EXP-310</button></th><td>Ben Okafor</td><td class="xp-amount">$96.20</td><td>Jun 25</td></tr>
<tr class="xp-detail"><td colspan="4"><div class="collapse" id="xp-310"><p><b>Team offsite supplies.</b> Whiteboard markers, sticky notes, and adapters from OfficeMart. Card ending 4417. Receipts attached (1).</p></div></td></tr>
<tr class="xp-row"><th scope="row"><button class="xp-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#xp-309" aria-expanded="false" aria-controls="xp-309"><svg class="xp-chev" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 0l7 5-7 5z"/></svg>EXP-309</button></th><td>Clara Nilsen</td><td class="xp-amount">$1,240.00</td><td>Jun 22</td></tr>
<tr class="xp-detail"><td colspan="4"><div class="collapse" id="xp-309"><p><b>Conference ticket, WebSummit.</b> Early-bird pass plus workshop add-on. Pending manager approval — flagged over the $1,000 single-item limit.</p></div></td></tr>
<tr class="xp-row"><th scope="row"><button class="xp-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#xp-308" aria-expanded="false" aria-controls="xp-308"><svg class="xp-chev" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 0l7 5-7 5z"/></svg>EXP-308</button></th><td>Igor Petrov</td><td class="xp-amount">$58.75</td><td>Jun 20</td></tr>
<tr class="xp-detail"><td colspan="4"><div class="collapse" id="xp-308"><p><b>Client lunch.</b> Two covers at Verde. Project code NW-114. Approved automatically under the $75 meal threshold.</p></div></td></tr>
</tbody>
</table>
</div>
CSS
/* Bootstrap .table whose detail rows are .collapse panels, opened by a button with data-bs-toggle="collapse".
The Collapse plugin keeps aria-expanded in sync and hides closed panels with display:none. */
.xp-title{font-size:1.15rem;font-weight:700;letter-spacing:-.01em;line-height:normal;margin:0 0 4px}
.xp-sub{color:#6f6353;font-size:.87rem;margin:0 0 20px}
.xp-card{background:#fff;border:1px solid #ece5da;border-radius:14px;box-shadow:0 12px 28px -18px rgba(74,58,36,.18)}
.xp-card:focus-visible{outline:2px solid #b45309;outline-offset:2px}
.xp{--bs-table-bg:#fff;--bs-table-color:#4a3d2e;--bs-table-border-color:#f3eee6;font-size:.9rem}
.xp>:not(caption)>*>*{border-bottom-width:0}
.xp>thead>tr>th{--bs-table-bg:#faf6f0;--bs-table-color:#6f6353;font-size:.73rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;white-space:nowrap;padding:12px 18px;border-bottom:1px solid #ece5da}
.xp>tbody>.xp-row>*{padding:13px 18px;border-top:1px solid var(--bs-table-border-color)}
.xp-row{cursor:pointer}
.xp-row:hover>*{--bs-table-bg-state:#fbf7f1}
.xp-row th{font-weight:600}
.xp-toggle{display:inline-flex;white-space:nowrap;align-items:center;gap:10px;padding:0;border:0;background:none;font:inherit;font-weight:600;color:#2a2118;border-radius:4px}
.xp-toggle:focus-visible{outline:2px solid #b45309;outline-offset:3px}
.xp-chev{width:10px;height:10px;fill:#9c8b72;transition:transform .2s ease}
.xp-toggle[aria-expanded="true"] .xp-chev{transform:rotate(90deg)}
.xp-amount{font-variant-numeric:tabular-nums;font-weight:600}
.xp>tbody>.xp-detail>td{padding:0}
.xp-detail p{margin:0;padding:16px 18px 18px 46px;background:#faf6ef;border-top:1px dashed #e7ddcd;font-size:.85rem;line-height:1.65;color:#6d5e4b}
.xp-detail b{color:#2a2118}
@media (max-width:480px){.xp>thead>tr>th,.xp>tbody>.xp-row>*{padding-left:10px;padding-right:10px}.xp-detail p{padding-left:30px;padding-right:10px}}
@media (prefers-reduced-motion:reduce){.xp-chev{transition:none}}
JavaScript
// Bootstrap's Collapse plugin handles the buttons; this only lets a click anywhere on the row press its button.
document.querySelectorAll('.xp-row').forEach(function (row) {
var btn = row.querySelector('.xp-toggle');
if (!btn) return;
row.addEventListener('click', function (e) {
if (!e.target.closest('button, a, input')) btn.click();
});
});
// Each toggle opens the detail row named in its aria-controls (data-open drives the group-data-open: utilities);
// a click anywhere else on the row presses the button.
document.querySelectorAll('[data-xp-toggle]').forEach(function (btn) {
var row = btn.closest('tr');
var detail = document.getElementById(btn.getAttribute('aria-controls'));
if (!row || !detail) return;
btn.addEventListener('click', function () {
var open = btn.getAttribute('aria-expanded') !== 'true';
btn.setAttribute('aria-expanded', String(open));
detail.toggleAttribute('data-open', open);
});
row.addEventListener('click', function (e) {
if (!e.target.closest('button, a, input')) btn.click();
});
});
React code
Requires: React 19, Tailwind CSS 4
JSX
// Table rows that expand a detail line: a real button with aria-expanded in each row, and a click
// anywhere else on the row does the same. The detail animates open with grid-template-rows 0fr → 1fr.
// Usage: <ExpandableTable claims={[{ id: 'EXP-311', employee: 'Sofia Marino', amount: '$482.90', date: 'Jun 27', summary: 'Client visit, Milan.', detail: 'Train fare $198.00 …' }]} />
import { useState, useId } from 'react';
const CLAIMS = [
{ id: 'EXP-311', employee: 'Sofia Marino', amount: '$482.90', date: 'Jun 27', summary: 'Client visit, Milan.', detail: 'Train fare $198.00 · Hotel one night $214.90 · Meals $70.00. Approved by L. Farrell. Receipts attached (3).' },
{ id: 'EXP-310', employee: 'Ben Okafor', amount: '$96.20', date: 'Jun 25', summary: 'Team offsite supplies.', detail: 'Whiteboard markers, sticky notes, and adapters from OfficeMart. Card ending 4417. Receipts attached (1).' },
{ id: 'EXP-309', employee: 'Clara Nilsen', amount: '$1,240.00', date: 'Jun 22', summary: 'Conference ticket, WebSummit.', detail: 'Early-bird pass plus workshop add-on. Pending manager approval — flagged over the $1,000 single-item limit.' },
{ id: 'EXP-308', employee: 'Igor Petrov', amount: '$58.75', date: 'Jun 20', summary: 'Client lunch.', detail: 'Two covers at Verde. Project code NW-114. Approved automatically under the $75 meal threshold.' },
];
function ClaimRows({ claim, open, onToggle }) {
const detailId = useId();
return (
<>
{/* Row click is a mouse convenience; keyboard users get the same action from the button. */}
<tr onClick={(e) => { if (!e.target.closest('button, a, input')) onToggle(); }}
className="cursor-pointer transition-colors duration-[120ms] hover:bg-[#fbf7f1] motion-reduce:transition-none">
<th scope="row">
<button type="button" aria-expanded={open} aria-controls={detailId} onClick={onToggle}
className="group inline-flex cursor-pointer whitespace-nowrap items-center gap-2.5 rounded font-semibold text-[#2a2118] focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#b45309]">
<svg viewBox="0 0 10 10" aria-hidden="true" className="size-2.5 fill-[#9c8b72] transition-transform duration-200 group-aria-expanded:rotate-90 motion-reduce:transition-none"><path d="M2 0l7 5-7 5z" /></svg>
{claim.id}
</button>
</th>
<td>{claim.employee}</td>
<td className="font-semibold tabular-nums">{claim.amount}</td>
<td>{claim.date}</td>
</tr>
<tr id={detailId} className="group" data-open={open || undefined}>
<td colSpan={4} className="border-t-0! p-0!">
<div className="invisible grid grid-rows-[0fr] transition-[grid-template-rows,visibility] duration-[250ms] group-data-open:visible group-data-open:grid-rows-[1fr] motion-reduce:transition-none">
<div className="min-h-0 overflow-hidden">
<p className="border-t border-dashed border-[#e7ddcd] bg-[#faf6ef] py-4 pr-[18px] pb-[18px] pl-[46px] text-[.85rem] max-[480px]:pr-2.5 max-[480px]:pl-[30px] leading-[1.65] text-[#6d5e4b] [&_b]:text-[#2a2118]"><b>{claim.summary}</b> {claim.detail}</p>
</div>
</div>
</td>
</tr>
</>
);
}
export default function ExpandableTable({
title = 'Expense claims',
description = 'Click a row to expand its detail line — an accessible accordion inside a table.',
claims = CLAIMS,
}) {
const titleId = useId();
const [open, setOpen] = useState(() => new Set());
const toggle = (id) => setOpen((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
return (
<>
<h2 id={titleId} className="mb-1 text-[1.15rem] font-bold tracking-[-.01em]">{title}</h2>
<p className="mb-5 text-[.87rem] text-[#6f6353]">{description}</p>
<div role="region" tabIndex={0} aria-label={`${title}, scrollable`} className="overflow-x-auto rounded-[14px] border border-[#ece5da] bg-white shadow-[0_12px_28px_-18px_rgba(74,58,36,.18)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#b45309]">
<table aria-labelledby={titleId} className="w-full border-collapse text-left text-[.9rem]">
<thead className="bg-[#faf6f0] text-[.73rem] font-bold tracking-[.06em] whitespace-nowrap text-[#6f6353] uppercase [&_th]:border-b [&_th]:border-[#ece5da] [&_th]:px-[18px] [&_th]:py-3 max-[480px]:[&_th]:px-2.5">
<tr><th scope="col">Claim</th><th scope="col">Employee</th><th scope="col">Amount</th><th scope="col">Date</th></tr>
</thead>
<tbody className="text-[#4a3d2e] [&>tr>*]:border-t [&>tr>*]:border-[#f3eee6] [&>tr>*]:px-[18px] [&>tr>*]:py-[13px] max-[480px]:[&>tr>*]:px-2.5">
{claims.map((c) => <ClaimRows key={c.id} claim={c} open={open.has(c.id)} onToggle={() => toggle(c.id)} />)}
</tbody>
</table>
</div>
</>
);
}
About this set
An expense claims table where each row opens a detail line underneath it, an accordion built inside a table. The claim number in the first cell is a real button with aria-expanded and aria-controls pointing at its detail row, and a click anywhere else on the row presses that button, so mouse users get a large target while keyboard and screen reader users get a proper disclosure control. A small triangle rotates 90 degrees when the row is open. The detail row spans all four columns and holds a short description of the claim. In the plain CSS and Tailwind versions it animates open by moving grid-template-rows from 0fr to 1fr, which fits text of any length without a fixed max-height, and it stays visibility:hidden while closed so its text is not read out. The Bootstrap version uses the Collapse plugin with data-bs-toggle=”collapse”, which keeps aria-expanded in sync for you. Use it for orders, transactions and support tickets where the extra detail is useful but would crowd the main table.
What’s included
Disclosure buttons with aria-expanded and aria-controls
Whole row is clickable, the button handles the keyboard
Height animation with grid-template-rows, no fixed max-height
Closed details hidden from screen readers
Bootstrap Collapse plugin version
Accessibility
Each claim number is a button that reports its expanded state and controls the detail row below. Closed details are visibility:hidden (display:none in Bootstrap), so they are skipped until opened. Buttons show a 2px amber focus outline, and the chevron, row and height transitions are removed under prefers-reduced-motion.
Customise it
Colours are in the .xp rules: header #faf6f0, detail background #faf6ef, chevron #9c8b72, focus #b45309; the open speed is the .25s on .xp-inner. Bootstrap uses --bs-table-* variables on .xp; in Tailwind edit duration-[250ms] and the colour utilities.