Accessible Tree Table with Nested Rows — Free JS Snippet
Accessible Tree Table with Expandable Nested Rows · Tables · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="tt-wrap">
<div class="tt-bar">
<input id="ttFilter" type="search" placeholder="Filter files and folders..." aria-label="Filter the tree">
<button type="button" id="ttExpand">Expand all</button>
<button type="button" id="ttCollapse">Collapse all</button>
</div>
<div class="tt-grid" role="treegrid" aria-label="Project files" id="ttGrid">
<div class="tt-head" role="row"><span role="columnheader">Name</span><span role="columnheader">Type</span><span role="columnheader">Modified</span><span role="columnheader" class="r">Size</span></div>
<div id="ttBody"></div>
</div>
<p class="tt-help" id="ttHelp">Arrow keys move, <kbd>→</kbd> opens a folder, <kbd>←</kbd> closes it or jumps to its parent, <kbd>Home</kbd>/<kbd>End</kbd> go to the first and last row.</p>
</div>body { background: #f2f4f9; padding: 18px; font-family: system-ui, sans-serif; }
.tt-wrap { max-width: 780px; margin: 0 auto; background: #fff; border: 1px solid #dde1ec; border-radius: 14px; padding: 14px; box-shadow: 0 8px 24px rgba(20,30,70,.06); }
.tt-bar { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
.tt-bar input { flex: 1; min-width: 200px; padding: 10px 12px; border: 1.5px solid #cfd5e4; border-radius: 10px; font: 500 14px/1.2 system-ui, sans-serif; }
.tt-bar input:focus { outline: 0; border-color: #4f46e5; box-shadow: 0 0 0 3px rgba(79,70,229,.14); }
.tt-bar button { font: 700 12.5px/1 system-ui, sans-serif; color: #384057; background: #eef1f6; border: 0; border-radius: 10px; padding: 0 14px; cursor: pointer; }
.tt-bar button:hover { background: #e0e5ee; }
.tt-grid { border: 1px solid #e0e4ee; border-radius: 10px; overflow: hidden; font-size: 13.5px; }
.tt-head, .tt-row { display: grid; grid-template-columns: minmax(0, 1fr) 90px 110px 90px; align-items: center; }
.tt-head { background: #f6f7fc; border-bottom: 1px solid #e0e4ee; font: 800 11.5px/1 system-ui, sans-serif; letter-spacing: .05em; text-transform: uppercase; color: #3a4260; }
.tt-head span { padding: 11px 12px; } .r { text-align: right; }
.tt-row { height: 38px; border-bottom: 1px solid #f0f2f8; color: #1b2033; cursor: default; outline: 0; }
.tt-row:hover { background: #f5f6ff; }
.tt-row:focus-visible { box-shadow: inset 0 0 0 2px #4f46e5; background: #eef0ff; }
.tt-row > span { padding: 0 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tt-name { display: flex; align-items: center; gap: 6px; font-weight: 600; }
.tt-tog { width: 22px; height: 22px; flex: none; display: grid; place-items: center; border: 0; background: none; border-radius: 6px; color: #5b6279; cursor: pointer; padding: 0; }
.tt-tog:hover { background: #dfe3f5; }
.tt-tog svg { transition: transform .15s; }
.tt-row[aria-expanded="true"] .tt-tog svg { transform: rotate(90deg); }
.tt-tog.leaf { visibility: hidden; }
.tt-ico { font-size: 15px; }
.tt-meta { color: #6b7290; font-size: 12.5px; }
.tt-size { text-align: right; font-variant-numeric: tabular-nums; color: #384057; }
.tt-row.folder .tt-size { color: #4338ca; font-weight: 700; }
.tt-row mark { background: #fde68a; color: inherit; border-radius: 3px; }
.tt-empty { padding: 26px; text-align: center; color: #6b7290; }
.tt-help { margin: 10px 2px 0; font-size: 12.5px; color: #6b7290; line-height: 1.8; }
kbd { font: 700 11px/1 ui-monospace, Menlo, monospace; background: #f0f2f8; border: 1px solid #d9dded; border-bottom-width: 2px; border-radius: 5px; padding: 2px 6px; color: #4a5270; }// Files carry a size in KB; a folder's size is the ROLL-UP of everything beneath it.
const TREE = [
{ name: 'src', children: [
{ name: 'components', children: [
{ name: 'Button.tsx', size: 4, mod: 'Sep 21' }, { name: 'Modal.tsx', size: 9, mod: 'Sep 19' },
{ name: 'Table', children: [{ name: 'Table.tsx', size: 18, mod: 'Sep 22' }, { name: 'Row.tsx', size: 6, mod: 'Sep 22' }] },
] },
{ name: 'hooks', children: [{ name: 'useDebounce.ts', size: 2, mod: 'Sep 12' }, { name: 'useFetch.ts', size: 5, mod: 'Sep 15' }] },
{ name: 'index.tsx', size: 3, mod: 'Sep 23' },
] },
{ name: 'public', children: [{ name: 'favicon.ico', size: 15, mod: 'Aug 30' }, { name: 'logo.svg', size: 7, mod: 'Sep 02' }] },
{ name: 'package.json', size: 2, mod: 'Sep 23' },
{ name: 'README.md', size: 6, mod: 'Sep 10' },
];
let uid = 0;
function prep(list, parent, level) {
list.forEach(function (n) {
n.id = ++uid; n.parent = parent; n.level = level;
if (n.children) { n.size = 0; prep(n.children, n, level + 1); n.children.forEach(function (c) { n.size += c.size; }); }
});
}
prep(TREE, null, 1);
const open = new Set([1, 2]); // ids of expanded folders: "src" and "components"
let query = '';
const body = document.getElementById('ttBody');
const fmt = function (kb) { return kb >= 1024 ? (kb / 1024).toFixed(1) + ' MB' : kb + ' KB'; };
const esc = function (s) { return s.replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); };
function mark(text) {
if (!query) return esc(text);
const i = text.toLowerCase().indexOf(query);
return i < 0 ? esc(text) : esc(text.slice(0, i)) + '<mark>' + esc(text.slice(i, i + query.length)) + '</mark>' + esc(text.slice(i + query.length));
}
// A node matches if its own name does, or any descendant does - so ancestors of a hit stay visible.
function matches(n) {
if (!query) return true;
if (n.name.toLowerCase().indexOf(query) !== -1) return true;
return !!n.children && n.children.some(matches);
}
// Flatten only the rows that are currently visible.
function visible() {
const out = [];
(function walk(list) {
list.forEach(function (n) {
if (!matches(n)) return;
out.push(n);
const isOpen = n.children && (query ? true : open.has(n.id)); // searching auto-opens folders that contain hits
if (isOpen) walk(n.children);
});
})(TREE);
return out;
}
const CHEV = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m9 6 6 6-6 6"/></svg>';
let focusId = 1;
function render() {
const rows = visible();
if (!rows.length) { body.innerHTML = '<div class="tt-empty">Nothing matches "' + esc(query) + '"</div>'; return; }
if (!rows.some(function (r) { return r.id === focusId; })) focusId = rows[0].id;
body.innerHTML = rows.map(function (n, i) {
const folder = !!n.children;
const isOpen = folder && (query ? true : open.has(n.id));
return '<div class="tt-row' + (folder ? ' folder' : '') + '" role="row" data-id="' + n.id + '" aria-level="' + n.level + '" aria-posinset="' + (i + 1) + '" aria-setsize="' + rows.length + '"' +
(folder ? ' aria-expanded="' + isOpen + '"' : '') + ' tabindex="' + (n.id === focusId ? 0 : -1) + '">' +
'<span role="gridcell" class="tt-name" style="padding-left:' + (12 + (n.level - 1) * 22) + 'px">' +
'<button type="button" tabindex="-1" class="tt-tog' + (folder ? '' : ' leaf') + '" aria-hidden="true">' + CHEV + '</button>' +
'<span class="tt-ico" aria-hidden="true">' + (folder ? (isOpen ? '📂' : '📁') : '📄') + '</span>' + mark(n.name) + '</span>' +
'<span role="gridcell" class="tt-meta">' + (folder ? 'Folder' : n.name.split('.').pop().toUpperCase()) + '</span>' +
'<span role="gridcell" class="tt-meta">' + (folder ? '' : n.mod) + '</span>' +
'<span role="gridcell" class="tt-size">' + fmt(n.size) + '</span></div>';
}).join('');
}
function toggle(id, force) {
if (query) return; // folders are forced open while filtering
const on = force === undefined ? !open.has(id) : force;
if (on) open.add(id); else open.delete(id);
render();
}
function focusRow(id) {
focusId = id; render();
const el = body.querySelector('[data-id="' + id + '"]'); if (el) el.focus();
}
const byId = function (id) { let f = null; (function w(l) { l.forEach(function (n) { if (n.id === id) f = n; if (n.children) w(n.children); }); })(TREE); return f; };
body.addEventListener('click', function (e) {
const row = e.target.closest('.tt-row'); if (!row) return;
const n = byId(Number(row.dataset.id));
if (n.children) toggle(n.id);
focusRow(n.id);
});
// The WAI-ARIA treegrid keyboard model.
body.addEventListener('keydown', function (e) {
const row = e.target.closest('.tt-row'); if (!row) return;
const rows = visible(), i = rows.findIndex(function (r) { return r.id === Number(row.dataset.id); }), n = rows[i];
const isOpen = n.children && (query ? true : open.has(n.id));
let handled = true;
if (e.key === 'ArrowDown') focusRow(rows[Math.min(rows.length - 1, i + 1)].id);
else if (e.key === 'ArrowUp') focusRow(rows[Math.max(0, i - 1)].id);
else if (e.key === 'Home') focusRow(rows[0].id);
else if (e.key === 'End') focusRow(rows[rows.length - 1].id);
else if (e.key === 'ArrowRight') { if (n.children && !isOpen) { toggle(n.id, true); focusRow(n.id); } else if (isOpen && n.children.length) focusRow(n.children.filter(matches)[0].id); }
else if (e.key === 'ArrowLeft') { if (n.children && isOpen && !query) { toggle(n.id, false); focusRow(n.id); } else if (n.parent) focusRow(n.parent.id); }
else if (e.key === 'Enter' || e.key === ' ') { if (n.children) { toggle(n.id); focusRow(n.id); } }
else handled = false;
if (handled) e.preventDefault();
});
document.getElementById('ttFilter').addEventListener('input', function (e) { query = e.target.value.trim().toLowerCase(); render(); });
document.getElementById('ttExpand').addEventListener('click', function () { (function w(l) { l.forEach(function (n) { if (n.children) { open.add(n.id); w(n.children); } }); })(TREE); render(); });
document.getElementById('ttCollapse').addEventListener('click', function () { open.clear(); render(); });
render();Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Accessible Tree Table with Expandable Rows — HTML, CSS & JavaScript

A tree table combines two structures that browsers do not natively combine: the columns of a table and the expandable hierarchy of a tree. File explorers, chart-of-accounts screens, organisation charts and nested task lists all use it. Building one that merely looks right is easy — indent some rows and toggle their visibility. Building one that behaves correctly for keyboard and screen-reader users is where the work is, and this snippet is structured around that.
The data model does two things worth noticing. Each node stores its parent and its depth, assigned once by a prep() pass, so indentation is a level times 22 pixels and "go to parent" is a property lookup. And folder sizes are a roll-up: a folder's size is the sum of everything beneath it, computed recursively when the tree is prepared, which is how file explorers show that a folder is 60 KB before you open it. Expansion state is kept separately in a Set of open folder ids, so rendering is a pure function of the tree, the open set and the current filter — call render() and the DOM is rebuilt to match.
Only currently visible rows are produced. A recursive walk emits a node and descends into its children only if the folder is open, which yields a flat list that maps straight onto table rows. Filtering has one subtle rule that most implementations get wrong: a node matches if its own name matches or any descendant does. Without the descendant check, searching for "Row" would hide the folders containing Row.tsx and the hit would appear orphaned at the top level. With it, ancestors of every match stay visible and are forced open while a filter is active, and the matched text is highlighted.
The accessibility model follows the WAI-ARIA treegrid pattern. The container has role="treegrid" and rows carry aria-level, aria-posinset, aria-setsize and, for folders, aria-expanded, so a screen reader can announce "Table, folder, expanded, level 3, 2 of 8". Focus uses a roving tabindex — exactly one row is tabindex 0 — so the whole grid is a single tab stop. The keyboard handler implements the standard interactions: Up and Down move between rows, Right opens a closed folder or steps into its first child, Left closes an open folder or jumps to the parent, Home and End go to the ends, and Enter or Space toggles. Chevron rotation is CSS-only, driven from aria-expanded.
Step by step
How to Use
- 1Expand and collapseClick a folder or its arrow to open it. Folder sizes are the total of everything inside, even when collapsed.
- 2Use the keyboardTab into the tree, then use the arrow keys. Right opens a folder, Left closes it or jumps to its parent.
- 3Expand allUse Expand all and Collapse all to open or fold the whole hierarchy at once.
- 4FilterType "Row" or "hook". Matching files appear with their parent folders kept open and the match highlighted.
- 5Check the semanticsInspect the rows to see aria-level, aria-expanded, aria-posinset and aria-setsize on each one.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
A tree is a hierarchical list. A treegrid is a hierarchy laid out in rows and columns, so each node can show multiple cells such as type, date and size.
It separates the state from the DOM, so render() can rebuild the view from the data, the open set and the filter every time.
If only the node's own name is tested, folders that contain a match are hidden and the result appears without context. Checking descendants keeps the ancestors visible.
Only one row has tabindex 0 and the others have -1, so the tree is a single tab stop, and arrow keys move focus within it.
Each folder's size is the sum of its children's sizes, computed recursively once when the tree is prepared.
Mark folders as having unloaded children, fetch them on first expand, insert the nodes, then call render() and set aria-busy while loading.
Yes. Use the JSX, Vue, Angular or Tailwind export buttons on this page. It uses no library, so port the tree data and the open-folder Set into component state, render the visible rows from them, and keep the roving tabindex and keyboard handler on the treegrid element.