Tree View
A hierarchical tree for browsing nested data like file systems. Uses native <details> for expand/collapse with ARIA tree roles.
File Explorer
Nested tree with folders and files.
-
src
-
components
- button.tsx
- card.tsx
- index.ts
- globals.css
-
- package.json
- README.md
<ul class="tree" role="tree" aria-label="File explorer">
<li class="tree-item" role="treeitem" aria-expanded="true">
<details class="tree-branch" open>
<summary class="tree-branch-trigger">
<svg>...chevron...</svg>
<svg>...folder...</svg>
<span>src</span>
</summary>
<ul class="tree-group" role="group">
<li class="tree-item" role="treeitem">
<span class="tree-leaf">
<svg>...file...</svg>
<span>index.ts</span>
</span>
</li>
</ul>
</details>
</li>
</ul>
CSS view file
Styles for the tree-view component. Uses design tokens for colors, spacing, and radius.
@layer components {
.tree {
list-style: none;
margin: 0;
padding: 0;
font-size: 0.875rem;
}
.tree-group {
list-style: none;
margin: 0;
padding: 0 0 0 1.25rem;
}
.tree-item {
padding: 0;
}
.tree-branch {
border: none;
& ::details-content {
block-size: 0;
overflow-y: clip;
transition: block-size 200ms ease, content-visibility 200ms allow-discrete;
}
&[open] ::details-content {
block-size: auto;
}
}
@starting-style {
.tree-branch[open] ::details-content {
block-size: 0;
}
}
.tree-branch-trigger {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
border-radius: var(--radius-md);
cursor: pointer;
color: var(--foreground);
list-style: none;
&:hover {
background-color: var(--accent);
}
&:focus-visible {
outline: 2px solid var(--ring);
outline-offset: -2px;
}
&::-webkit-details-marker {
display: none;
}
& svg {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
& svg:first-child {
width: 0.875rem;
height: 0.875rem;
color: var(--muted-foreground);
transition: transform 200ms ease;
}
}
details[open] > .tree-branch-trigger > svg:first-child {
transform: rotate(90deg);
}
.tree-leaf {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
padding-left: calc(0.5rem + 0.875rem + 0.375rem);
border-radius: var(--radius-md);
color: var(--foreground);
cursor: default;
&:hover {
background-color: var(--accent);
}
& svg {
width: 1rem;
height: 1rem;
flex-shrink: 0;
color: var(--muted-foreground);
}
}
}
JavaScript view file
Interaction logic for the tree-view component. Uses data attributes for wiring.
// -- Tree View ------------------------------------------------
// Keyboard navigation and ARIA state for tree views.
function init() {
document.querySelectorAll('.tree[role="tree"]:not([data-init])').forEach((tree) => {
tree.dataset.init = '';
/* Keep aria-expanded in sync with <details> open state */
tree.querySelectorAll('.tree-branch').forEach((details) => {
const treeitem = details.closest('[role="treeitem"]');
if (!treeitem) return;
details.addEventListener('toggle', () => {
treeitem.setAttribute('aria-expanded', String(details.open));
});
});
/* Keyboard navigation */
tree.addEventListener('keydown', (e) => {
const target = e.target.closest('.tree-branch-trigger, .tree-leaf');
if (!target) return;
const allItems = Array.from(tree.querySelectorAll('.tree-branch-trigger, .tree-leaf'));
const visibleItems = allItems.filter((item) => item.checkVisibility());
const index = visibleItems.indexOf(target);
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (index < visibleItems.length - 1) visibleItems[index + 1].focus();
break;
case 'ArrowUp':
e.preventDefault();
if (index > 0) visibleItems[index - 1].focus();
break;
case 'ArrowRight':
e.preventDefault();
{ const detailsR = target.closest('details.tree-branch');
if (detailsR && !detailsR.open) detailsR.open = true; }
break;
case 'ArrowLeft':
e.preventDefault();
{ const detailsL = target.closest('details.tree-branch');
if (detailsL && detailsL.open) detailsL.open = false; }
break;
case 'Home':
e.preventDefault();
if (visibleItems.length) visibleItems[0].focus();
break;
case 'End':
e.preventDefault();
if (visibleItems.length) visibleItems[visibleItems.length - 1].focus();
break;
}
});
});
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });