Open demo

CSS code

HTML
<div class="tcard">
  <div class="tscroll" role="region" tabindex="0" aria-label="Project files, scrollable">
  <table class="tbl" data-tree>
    <caption class="sr-only">Project files as a tree. Folder names are buttons that expand or collapse their contents.</caption>
    <thead><tr><th scope="col">Name</th><th scope="col">Owner</th><th scope="col" class="num">Size</th><th scope="col" class="num">Modified</th></tr></thead>
    <tbody>
      <tr class="folder" data-level="1" style="--d:0"><td class="name"><button type="button" class="nm" aria-expanded="true"><span class="tw"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg></span><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg> src<span class="sr-only">, level 1</span></button></td><td>Aigars K.</td><td class="num sz">—</td><td class="num">2 hours ago</td></tr>
      <tr class="folder" data-level="2" style="--d:1"><td class="name"><button type="button" class="nm" aria-expanded="true"><span class="tw"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg></span><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg> components<span class="sr-only">, level 2</span></button></td><td>Rosa M.</td><td class="num sz">—</td><td class="num">Yesterday</td></tr>
      <tr class="file" data-level="3" style="--d:2"><td class="name"><span class="nm"><span class="tw"></span><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V8z"/><path d="M14 3v5h5"/></svg> Header.astro<span class="sr-only">, level 3</span></span></td><td>Rosa M.</td><td class="num sz">4.2 KB</td><td class="num">Yesterday</td></tr>
      <tr class="file" data-level="3" style="--d:2"><td class="name"><span class="nm"><span class="tw"></span><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V8z"/><path d="M14 3v5h5"/></svg> Footer.astro<span class="sr-only">, level 3</span></span></td><td>Rosa M.</td><td class="num sz">2.8 KB</td><td class="num">3 days ago</td></tr>
      <tr class="folder" data-level="2" style="--d:1"><td class="name"><button type="button" class="nm" aria-expanded="false"><span class="tw"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg></span><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg> styles<span class="sr-only">, level 2</span></button></td><td>Tomas P.</td><td class="num sz">—</td><td class="num">4 days ago</td></tr>
      <tr hidden class="file" data-level="3" style="--d:2"><td class="name"><span class="nm"><span class="tw"></span><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V8z"/><path d="M14 3v5h5"/></svg> input.css<span class="sr-only">, level 3</span></span></td><td>Tomas P.</td><td class="num sz">11.4 KB</td><td class="num">4 days ago</td></tr>
      <tr class="file" data-level="2" style="--d:1"><td class="name"><span class="nm"><span class="tw"></span><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V8z"/><path d="M14 3v5h5"/></svg> main.ts<span class="sr-only">, level 2</span></span></td><td>Aigars K.</td><td class="num sz">8.9 KB</td><td class="num">2 hours ago</td></tr>
      <tr class="file" data-level="1" style="--d:0"><td class="name"><span class="nm"><span class="tw"></span><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V8z"/><path d="M14 3v5h5"/></svg> package.json<span class="sr-only">, level 1</span></span></td><td>Aigars K.</td><td class="num sz">1.1 KB</td><td class="num">Last week</td></tr>
    </tbody>
  </table>
  </div>
</div>
CSS
/* Tree table: flat rows in depth-first order. Each row sets --d (indent steps) and aria-level;
   folders hold a button with aria-expanded and the script hides rows under a collapsed folder. */
.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{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 thead th.num{text-align:right}

.tbl td.name{padding-left:calc(16px + var(--d,0) * 22px)}
.tbl .nm{display:flex;align-items:center;gap:9px}
.tbl button.nm{background:none;border:0;padding:0;font:inherit;color:inherit;cursor:pointer;border-radius:4px}
.tbl button.nm:focus-visible{outline:2px solid #4f46e5;outline-offset:2px}
.tbl .tw{width:15px;height:15px;flex-shrink:0;display:grid;place-items:center}
.tbl .tw svg{display:block;width:11px;height:11px;stroke:#98a2b3;fill:none;stroke-width:2.8;transition:transform .18s}
.tbl [aria-expanded=true] .tw svg{transform:rotate(90deg)}
.tbl .ic{display:block;width:17px;height:17px;flex-shrink:0;stroke-width:1.8;fill:none}
.tbl .folder .ic{stroke:#d97706}
.tbl .file .ic{stroke:#98a2b3}
.tbl tr.folder{cursor:pointer}
.tbl .sz{color:#667085}
@media(prefers-reduced-motion:reduce){.tbl .tw svg{transition:none}}
JavaScript
// Tree table. Rows are flat and ordered depth-first; data-level gives each row's depth. A row is
// shown only when every ancestor folder above it is expanded, so opening a folder reveals its
// children but leaves a collapsed subfolder's contents hidden.
document.querySelectorAll('[data-tree]').forEach(table => {
  const rows = [...table.tBodies[0].rows];
  const level = row => Number(row.dataset.level);
  const sync = () => {
    const open = [];
    rows.forEach(row => {
      const l = level(row);
      row.hidden = open.slice(1, l).some(o => !o);
      const btn = row.querySelector('button[aria-expanded]');
      open.length = l;
      open[l] = !btn || btn.getAttribute('aria-expanded') === 'true';
    });
  };
  rows.forEach(row => {
    const btn = row.querySelector('button[aria-expanded]');
    if (!btn) return;
    btn.addEventListener('click', () => {
      btn.setAttribute('aria-expanded', btn.getAttribute('aria-expanded') !== 'true');
      sync();
    });
    row.addEventListener('click', e => { if (!e.target.closest('button')) btn.click(); });
  });
  sync();
});

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="Project files, scrollable">
  <table class="table table-hover align-middle mb-0 tbl" data-tree>
    <caption class="visually-hidden">Project files as a tree. Folder names are buttons that expand or collapse their contents.</caption>
    <thead><tr><th scope="col">Name</th><th scope="col">Owner</th><th scope="col" class="text-end">Size</th><th scope="col" class="text-end">Modified</th></tr></thead>
    <tbody>
      <tr class="folder" data-level="1" style="--d:0"><td class="name"><button type="button" class="nm" aria-expanded="true"><span class="tw"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg></span><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg> src<span class="visually-hidden">, level 1</span></button></td><td>Aigars K.</td><td class="text-end num sz">—</td><td class="text-end num">2 hours ago</td></tr>
      <tr class="folder" data-level="2" style="--d:1"><td class="name"><button type="button" class="nm" aria-expanded="true"><span class="tw"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg></span><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg> components<span class="visually-hidden">, level 2</span></button></td><td>Rosa M.</td><td class="text-end num sz">—</td><td class="text-end num">Yesterday</td></tr>
      <tr class="file" data-level="3" style="--d:2"><td class="name"><span class="nm"><span class="tw"></span><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V8z"/><path d="M14 3v5h5"/></svg> Header.astro<span class="visually-hidden">, level 3</span></span></td><td>Rosa M.</td><td class="text-end num sz">4.2 KB</td><td class="text-end num">Yesterday</td></tr>
      <tr class="file" data-level="3" style="--d:2"><td class="name"><span class="nm"><span class="tw"></span><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V8z"/><path d="M14 3v5h5"/></svg> Footer.astro<span class="visually-hidden">, level 3</span></span></td><td>Rosa M.</td><td class="text-end num sz">2.8 KB</td><td class="text-end num">3 days ago</td></tr>
      <tr class="folder" data-level="2" style="--d:1"><td class="name"><button type="button" class="nm" aria-expanded="false"><span class="tw"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg></span><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg> styles<span class="visually-hidden">, level 2</span></button></td><td>Tomas P.</td><td class="text-end num sz">—</td><td class="text-end num">4 days ago</td></tr>
      <tr hidden class="file" data-level="3" style="--d:2"><td class="name"><span class="nm"><span class="tw"></span><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V8z"/><path d="M14 3v5h5"/></svg> input.css<span class="visually-hidden">, level 3</span></span></td><td>Tomas P.</td><td class="text-end num sz">11.4 KB</td><td class="text-end num">4 days ago</td></tr>
      <tr class="file" data-level="2" style="--d:1"><td class="name"><span class="nm"><span class="tw"></span><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V8z"/><path d="M14 3v5h5"/></svg> main.ts<span class="visually-hidden">, level 2</span></span></td><td>Aigars K.</td><td class="text-end num sz">8.9 KB</td><td class="text-end num">2 hours ago</td></tr>
      <tr class="file" data-level="1" style="--d:0"><td class="name"><span class="nm"><span class="tw"></span><svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V8z"/><path d="M14 3v5h5"/></svg> package.json<span class="visually-hidden">, level 1</span></span></td><td>Aigars K.</td><td class="text-end num sz">1.1 KB</td><td class="text-end num">Last week</td></tr>
    </tbody>
  </table>
  </div>
</div>
CSS
/* Bootstrap .card + .table-responsive + .table-hover as a tree. Each row sets --d (indent steps)
   and aria-level; folders hold a button with aria-expanded, and the chevron turns from that. */
.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}
.tbl > tbody > tr > td.name{padding-left:calc(16px + var(--d,0) * 22px)}
.tbl .nm{display:flex;align-items:center;gap:9px}
.tbl button.nm{background:none;border:0;padding:0;color:inherit;border-radius:4px}
.tbl button.nm:focus-visible{outline:2px solid #4f46e5;outline-offset:2px}
.tbl .tw{width:15px;height:15px;flex-shrink:0;display:grid;place-items:center}
.tbl .tw svg{display:block;width:11px;height:11px;stroke:#98a2b3;fill:none;stroke-width:2.8;transition:transform .18s}
.tbl [aria-expanded=true] .tw svg{transform:rotate(90deg)}
.tbl .ic{display:block;width:17px;height:17px;flex-shrink:0;stroke-width:1.8;fill:none}
.tbl .folder .ic{stroke:#d97706}
.tbl .file .ic{stroke:#98a2b3}
.tbl tr.folder{cursor:pointer}
.tbl > tbody > tr > .sz{--bs-table-color:#667085}
@media(prefers-reduced-motion:reduce){.tbl .tw svg{transition:none}}
JavaScript
// Tree table. Rows are flat and ordered depth-first; data-level gives each row's depth. A row is
// shown only when every ancestor folder above it is expanded, so opening a folder reveals its
// children but leaves a collapsed subfolder's contents hidden.
document.querySelectorAll('[data-tree]').forEach(table => {
  const rows = [...table.tBodies[0].rows];
  const level = row => Number(row.dataset.level);
  const sync = () => {
    const open = [];
    rows.forEach(row => {
      const l = level(row);
      row.hidden = open.slice(1, l).some(o => !o);
      const btn = row.querySelector('button[aria-expanded]');
      open.length = l;
      open[l] = !btn || btn.getAttribute('aria-expanded') === 'true';
    });
  };
  rows.forEach(row => {
    const btn = row.querySelector('button[aria-expanded]');
    if (!btn) return;
    btn.addEventListener('click', () => {
      btn.setAttribute('aria-expanded', btn.getAttribute('aria-expanded') !== 'true');
      sync();
    });
    row.addEventListener('click', e => { if (!e.target.closest('button')) btn.click(); });
  });
  sync();
});

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="Project files, scrollable">
  <table class="w-full border-collapse text-[.89rem] whitespace-nowrap [&_td]:border-b [&_td]:border-[#f2f4f7] [&_td]:px-4 [&_td]:py-[13px] [&_td]:text-[#3c4048] [&_tbody_tr:last-child_td]:border-b-0" data-tree>
    <caption class="sr-only">Project files as a tree. Folder names are buttons that expand or collapse their contents.</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">Name</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">Owner</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">Size</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">Modified</th></tr></thead>
    <tbody>
      <tr class="hover:bg-[#fafbfd] cursor-pointer" data-level="1" style="--d:0"><td class="pl-[calc(16px+var(--d)*22px)]!"><button type="button" class="group flex cursor-pointer items-center gap-[9px] rounded focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5]" aria-expanded="true"><span class="grid size-[15px] shrink-0 place-items-center"><svg class="size-[11px] fill-none stroke-[#98a2b3] stroke-[2.8] transition-transform duration-[180ms] 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></span><svg class="size-[17px] shrink-0 fill-none stroke-[1.8] stroke-[#d97706]" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg> src<span class="sr-only">, level 1</span></button></td><td>Aigars K.</td><td class="text-right tabular-nums text-[#667085]!">—</td><td class="text-right tabular-nums">2 hours ago</td></tr>
      <tr class="hover:bg-[#fafbfd] cursor-pointer" data-level="2" style="--d:1"><td class="pl-[calc(16px+var(--d)*22px)]!"><button type="button" class="group flex cursor-pointer items-center gap-[9px] rounded focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5]" aria-expanded="true"><span class="grid size-[15px] shrink-0 place-items-center"><svg class="size-[11px] fill-none stroke-[#98a2b3] stroke-[2.8] transition-transform duration-[180ms] 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></span><svg class="size-[17px] shrink-0 fill-none stroke-[1.8] stroke-[#d97706]" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg> components<span class="sr-only">, level 2</span></button></td><td>Rosa M.</td><td class="text-right tabular-nums text-[#667085]!">—</td><td class="text-right tabular-nums">Yesterday</td></tr>
      <tr class="hover:bg-[#fafbfd]" data-level="3" style="--d:2"><td class="pl-[calc(16px+var(--d)*22px)]!"><span class="flex items-center gap-[9px]"><span class="size-[15px] shrink-0"></span><svg class="size-[17px] shrink-0 fill-none stroke-[1.8] stroke-[#98a2b3]" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V8z"/><path d="M14 3v5h5"/></svg> Header.astro<span class="sr-only">, level 3</span></span></td><td>Rosa M.</td><td class="text-right tabular-nums text-[#667085]!">4.2 KB</td><td class="text-right tabular-nums">Yesterday</td></tr>
      <tr class="hover:bg-[#fafbfd]" data-level="3" style="--d:2"><td class="pl-[calc(16px+var(--d)*22px)]!"><span class="flex items-center gap-[9px]"><span class="size-[15px] shrink-0"></span><svg class="size-[17px] shrink-0 fill-none stroke-[1.8] stroke-[#98a2b3]" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V8z"/><path d="M14 3v5h5"/></svg> Footer.astro<span class="sr-only">, level 3</span></span></td><td>Rosa M.</td><td class="text-right tabular-nums text-[#667085]!">2.8 KB</td><td class="text-right tabular-nums">3 days ago</td></tr>
      <tr class="hover:bg-[#fafbfd] cursor-pointer" data-level="2" style="--d:1"><td class="pl-[calc(16px+var(--d)*22px)]!"><button type="button" class="group flex cursor-pointer items-center gap-[9px] rounded focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5]" aria-expanded="false"><span class="grid size-[15px] shrink-0 place-items-center"><svg class="size-[11px] fill-none stroke-[#98a2b3] stroke-[2.8] transition-transform duration-[180ms] 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></span><svg class="size-[17px] shrink-0 fill-none stroke-[1.8] stroke-[#d97706]" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg> styles<span class="sr-only">, level 2</span></button></td><td>Tomas P.</td><td class="text-right tabular-nums text-[#667085]!">—</td><td class="text-right tabular-nums">4 days ago</td></tr>
      <tr hidden class="hover:bg-[#fafbfd]" data-level="3" style="--d:2"><td class="pl-[calc(16px+var(--d)*22px)]!"><span class="flex items-center gap-[9px]"><span class="size-[15px] shrink-0"></span><svg class="size-[17px] shrink-0 fill-none stroke-[1.8] stroke-[#98a2b3]" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V8z"/><path d="M14 3v5h5"/></svg> input.css<span class="sr-only">, level 3</span></span></td><td>Tomas P.</td><td class="text-right tabular-nums text-[#667085]!">11.4 KB</td><td class="text-right tabular-nums">4 days ago</td></tr>
      <tr class="hover:bg-[#fafbfd]" data-level="2" style="--d:1"><td class="pl-[calc(16px+var(--d)*22px)]!"><span class="flex items-center gap-[9px]"><span class="size-[15px] shrink-0"></span><svg class="size-[17px] shrink-0 fill-none stroke-[1.8] stroke-[#98a2b3]" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V8z"/><path d="M14 3v5h5"/></svg> main.ts<span class="sr-only">, level 2</span></span></td><td>Aigars K.</td><td class="text-right tabular-nums text-[#667085]!">8.9 KB</td><td class="text-right tabular-nums">2 hours ago</td></tr>
      <tr class="hover:bg-[#fafbfd]" data-level="1" style="--d:0"><td class="pl-[calc(16px+var(--d)*22px)]!"><span class="flex items-center gap-[9px]"><span class="size-[15px] shrink-0"></span><svg class="size-[17px] shrink-0 fill-none stroke-[1.8] stroke-[#98a2b3]" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V8z"/><path d="M14 3v5h5"/></svg> package.json<span class="sr-only">, level 1</span></span></td><td>Aigars K.</td><td class="text-right tabular-nums text-[#667085]!">1.1 KB</td><td class="text-right tabular-nums">Last week</td></tr>
    </tbody>
  </table>
  </div>
</div>
JavaScript
// Tree table. Rows are flat and ordered depth-first; data-level gives each row's depth. A row is
// shown only when every ancestor folder above it is expanded, so opening a folder reveals its
// children but leaves a collapsed subfolder's contents hidden.
document.querySelectorAll('[data-tree]').forEach(table => {
  const rows = [...table.tBodies[0].rows];
  const level = row => Number(row.dataset.level);
  const sync = () => {
    const open = [];
    rows.forEach(row => {
      const l = level(row);
      row.hidden = open.slice(1, l).some(o => !o);
      const btn = row.querySelector('button[aria-expanded]');
      open.length = l;
      open[l] = !btn || btn.getAttribute('aria-expanded') === 'true';
    });
  };
  rows.forEach(row => {
    const btn = row.querySelector('button[aria-expanded]');
    if (!btn) return;
    btn.addEventListener('click', () => {
      btn.setAttribute('aria-expanded', btn.getAttribute('aria-expanded') !== 'true');
      sync();
    });
    row.addEventListener('click', e => { if (!e.target.closest('button')) btn.click(); });
  });
  sync();
});

About this set

A file browser table with folders that open and close to show nested files, three levels deep, next to owner, size and modified columns. Rows are flat and in depth-first order, the way most APIs return a tree. Each row sets a –d custom property for its depth, and the name cell’s left padding is 16 pixels plus 22 pixels per level, so indentation needs no extra wrappers. A data-level attribute tells the script each row’s depth. A row is shown only when every folder above it is open, so opening src reveals components and styles, while the contents of a collapsed styles folder stay hidden until it is opened too. Folder names are buttons with aria-expanded and a rotating chevron, and a click anywhere on a folder row does the same. Every name cell also carries visually hidden text such as level 2, so screen reader users hear the depth that sighted users see as indentation. Use it for file managers, category trees, org charts and nested budgets. Folder icons are amber and file icons grey.

What’s included

  • Depth from one --d custom property per row
  • Folders toggle with buttons that carry aria-expanded
  • Nested folders keep their own open or closed state
  • Depth also given as hidden text for screen readers
  • Works with flat, depth-first row data

Accessibility

The table has a caption and column headers with scope="col". Each folder is a native button with aria-expanded and a 2px focus outline, collapsed rows use the hidden attribute, and each name includes visually hidden level text. Icons are aria-hidden, and the chevron rotation is removed under prefers-reduced-motion.

Customise it

Change the 22px step in the name cell's padding calc for a deeper or shallower indent, and the icon colours in .folder .ic and .file .ic (plain CSS and Bootstrap) or the stroke- utilities on each icon (Tailwind).