UI Components Hard
ARIA Tree View
Accessible tree view component with full keyboard navigation and ARIA tree roles for hierarchical data display.
Open in Lab
MCP
vanilla-js css
Targets: JS HTML
Code
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
background: #0a0a0a;
color: #e4e4e7;
line-height: 1.6;
min-height: 100vh;
}
.demo {
max-width: 560px;
margin: 0 auto;
padding: 3rem 1.5rem;
}
.demo-title {
font-size: 1.75rem;
font-weight: 700;
color: #fafafa;
letter-spacing: -0.02em;
}
.demo-sub {
color: #a1a1aa;
margin-top: 0.25rem;
font-size: 0.95rem;
}
/* Tree Section */
.tree-section {
margin-top: 2rem;
background: #111113;
border: 1px solid #27272a;
border-radius: 12px;
overflow: hidden;
}
.tree-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.85rem 1rem;
background: #0e0e10;
border-bottom: 1px solid #27272a;
color: #a1a1aa;
}
.tree-header-icon {
flex-shrink: 0;
}
.tree-header-title {
font-size: 0.85rem;
font-weight: 600;
color: #d4d4d8;
}
/* Tree */
.tree {
list-style: none;
padding: 0.5rem 0;
font-size: 0.85rem;
}
.tree ul {
list-style: none;
}
.tree-group {
margin: 0;
}
.tree-group[hidden] {
display: none;
}
.tree-item {
position: relative;
outline: none;
}
.tree-item-content {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.75rem;
cursor: pointer;
border-radius: 4px;
margin: 0 4px;
transition: background 0.1s;
}
.tree-item:hover > .tree-item-content {
background: #1e1e22;
}
.tree-item:focus > .tree-item-content {
background: rgba(59, 130, 246, 0.1);
outline: 2px solid #3b82f6;
outline-offset: -2px;
}
.tree-item[aria-selected="true"] > .tree-item-content {
background: rgba(59, 130, 246, 0.12);
}
.tree-item[aria-selected="true"] > .tree-item-content .tree-label {
color: #60a5fa;
}
/* Indent based on aria-level */
.tree-item[aria-level="1"] > .tree-item-content {
padding-left: 0.75rem;
}
.tree-item[aria-level="2"] > .tree-item-content {
padding-left: 1.75rem;
}
.tree-item[aria-level="3"] > .tree-item-content {
padding-left: 2.75rem;
}
/* Indent lines */
.tree-item[aria-level="2"]::before {
content: "";
position: absolute;
left: 1.35rem;
top: 0;
bottom: 0;
width: 1px;
background: #1e1e22;
}
.tree-item[aria-level="3"]::before {
content: "";
position: absolute;
left: 2.35rem;
top: 0;
bottom: 0;
width: 1px;
background: #1e1e22;
}
/* Chevron */
.tree-chevron {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: transform 0.15s;
}
.tree-chevron::before {
content: "";
width: 0;
height: 0;
border-left: 5px solid #52525b;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
}
.tree-item[aria-expanded="true"] > .tree-item-content .tree-chevron {
transform: rotate(90deg);
}
.tree-item[aria-expanded="true"] > .tree-item-content .tree-chevron::before {
border-left-color: #a1a1aa;
}
.tree-spacer {
width: 16px;
height: 16px;
display: inline-block;
flex-shrink: 0;
}
/* File Icons */
.tree-icon {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 0.65rem;
font-weight: 700;
border-radius: 3px;
}
.tree-icon--folder {
color: #f59e0b;
}
.tree-icon--folder::before {
content: "";
width: 14px;
height: 11px;
background: currentColor;
border-radius: 1px 3px 3px 3px;
position: relative;
clip-path: polygon(0% 20%, 35% 20%, 45% 0%, 100% 0%, 100% 100%, 0% 100%);
}
.tree-item[aria-expanded="true"] > .tree-item-content .tree-icon--folder {
color: #fbbf24;
}
.tree-icon--tsx {
background: #1e40af;
color: #93c5fd;
}
.tree-icon--tsx::before {
content: "TX";
}
.tree-icon--ts {
background: #1e3a5f;
color: #60a5fa;
}
.tree-icon--ts::before {
content: "TS";
}
.tree-icon--css {
background: #1e3a5f;
color: #38bdf8;
}
.tree-icon--css::before {
content: "CS";
}
.tree-icon--json {
background: #365314;
color: #86efac;
}
.tree-icon--json::before {
content: "{}";
}
.tree-icon--img {
background: #4a1d6a;
color: #c084fc;
}
.tree-icon--img::before {
content: "IM";
}
.tree-icon--file {
background: #27272a;
color: #a1a1aa;
}
.tree-icon--file::before {
content: "FI";
}
.tree-label {
color: #d4d4d8;
user-select: none;
}
.tree-item--folder > .tree-item-content .tree-label {
font-weight: 500;
color: #e4e4e7;
}
/* Info Panel */
.info-panel {
margin-top: 2rem;
background: #111113;
border: 1px solid #27272a;
border-radius: 12px;
padding: 1.5rem;
}
.info-title {
font-size: 1rem;
font-weight: 600;
color: #fafafa;
margin-bottom: 0.75rem;
}
.kbd-grid {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.kbd-item {
font-size: 0.825rem;
color: #a1a1aa;
}
kbd {
display: inline-block;
padding: 0.08rem 0.4rem;
background: #1e1e22;
border: 1px solid #3f3f46;
border-radius: 4px;
font-size: 0.73rem;
font-family: "SF Mono", "Fira Code", monospace;
color: #d4d4d8;
margin-right: 0.15rem;
}(() => {
const tree = document.getElementById("file-tree");
function getVisibleItems() {
const items = [];
function walk(parent) {
const children = parent.querySelectorAll(":scope > [role='treeitem']");
children.forEach((item) => {
items.push(item);
const expanded = item.getAttribute("aria-expanded");
if (expanded === "true") {
const group = item.querySelector(":scope > [role='group']");
if (group) walk(group);
}
});
}
walk(tree);
return items;
}
function focusItem(item) {
// Remove tabindex from all items
tree.querySelectorAll("[role='treeitem']").forEach((el) => {
el.setAttribute("tabindex", "-1");
});
item.setAttribute("tabindex", "0");
item.focus();
}
function toggleExpand(item) {
const expanded = item.getAttribute("aria-expanded");
if (expanded === null) return; // Not a folder
const group = item.querySelector(":scope > [role='group']");
if (!group) return;
if (expanded === "true") {
item.setAttribute("aria-expanded", "false");
group.hidden = true;
} else {
item.setAttribute("aria-expanded", "true");
group.hidden = false;
}
}
function expandItem(item) {
if (item.getAttribute("aria-expanded") === "false") {
const group = item.querySelector(":scope > [role='group']");
if (group) {
item.setAttribute("aria-expanded", "true");
group.hidden = false;
}
}
}
function collapseItem(item) {
if (item.getAttribute("aria-expanded") === "true") {
const group = item.querySelector(":scope > [role='group']");
if (group) {
item.setAttribute("aria-expanded", "false");
group.hidden = true;
}
}
}
function getParentItem(item) {
const group = item.parentElement;
if (group && group.getAttribute("role") === "group") {
return group.closest("[role='treeitem']");
}
return null;
}
function toggleSelection(item) {
const selected = item.getAttribute("aria-selected") === "true";
// Deselect all
tree.querySelectorAll("[role='treeitem']").forEach((el) => {
el.setAttribute("aria-selected", "false");
});
// Select this one (toggle off if already selected)
if (!selected) {
item.setAttribute("aria-selected", "true");
}
}
// Click handler
tree.addEventListener("click", (e) => {
const item = e.target.closest("[role='treeitem']");
if (!item) return;
// Check if clicking on the chevron area or the content
const content = e.target.closest(".tree-item-content");
if (!content) return;
focusItem(item);
// If it's a folder, toggle expand
if (item.getAttribute("aria-expanded") !== null) {
toggleExpand(item);
}
toggleSelection(item);
});
// Keyboard handler
tree.addEventListener("keydown", (e) => {
const currentItem = document.activeElement;
if (!currentItem || currentItem.getAttribute("role") !== "treeitem") return;
const visibleItems = getVisibleItems();
const currentIndex = visibleItems.indexOf(currentItem);
switch (e.key) {
case "ArrowDown": {
e.preventDefault();
if (currentIndex < visibleItems.length - 1) {
focusItem(visibleItems[currentIndex + 1]);
}
break;
}
case "ArrowUp": {
e.preventDefault();
if (currentIndex > 0) {
focusItem(visibleItems[currentIndex - 1]);
}
break;
}
case "ArrowRight": {
e.preventDefault();
const expanded = currentItem.getAttribute("aria-expanded");
if (expanded === "false") {
// Expand the folder
expandItem(currentItem);
} else if (expanded === "true") {
// Move to first child
const group = currentItem.querySelector(":scope > [role='group']");
if (group) {
const firstChild = group.querySelector("[role='treeitem']");
if (firstChild) focusItem(firstChild);
}
}
break;
}
case "ArrowLeft": {
e.preventDefault();
const exp = currentItem.getAttribute("aria-expanded");
if (exp === "true") {
// Collapse the folder
collapseItem(currentItem);
} else {
// Move to parent
const parent = getParentItem(currentItem);
if (parent) focusItem(parent);
}
break;
}
case "Home": {
e.preventDefault();
if (visibleItems.length > 0) {
focusItem(visibleItems[0]);
}
break;
}
case "End": {
e.preventDefault();
if (visibleItems.length > 0) {
focusItem(visibleItems[visibleItems.length - 1]);
}
break;
}
case "Enter":
case " ": {
e.preventDefault();
if (currentItem.getAttribute("aria-expanded") !== null) {
toggleExpand(currentItem);
}
toggleSelection(currentItem);
break;
}
}
});
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ARIA Tree View</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="demo">
<h1 class="demo-title">ARIA Tree View</h1>
<p class="demo-sub">Accessible hierarchical tree with full keyboard navigation and ARIA tree roles.</p>
<div class="tree-section">
<div class="tree-header">
<svg class="tree-header-icon" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<span class="tree-header-title">Project Files</span>
</div>
<ul role="tree" aria-label="Project file structure" class="tree" id="file-tree">
<!-- src/ -->
<li role="treeitem" aria-expanded="true" aria-level="1" aria-selected="false" class="tree-item tree-item--folder" tabindex="0">
<span class="tree-item-content">
<span class="tree-chevron" aria-hidden="true"></span>
<span class="tree-icon tree-icon--folder" aria-hidden="true"></span>
<span class="tree-label">src</span>
</span>
<ul role="group" class="tree-group">
<!-- src/components/ -->
<li role="treeitem" aria-expanded="false" aria-level="2" aria-selected="false" class="tree-item tree-item--folder" tabindex="-1">
<span class="tree-item-content">
<span class="tree-chevron" aria-hidden="true"></span>
<span class="tree-icon tree-icon--folder" aria-hidden="true"></span>
<span class="tree-label">components</span>
</span>
<ul role="group" class="tree-group" hidden>
<li role="treeitem" aria-level="3" aria-selected="false" class="tree-item tree-item--file" tabindex="-1">
<span class="tree-item-content">
<span class="tree-spacer" aria-hidden="true"></span>
<span class="tree-icon tree-icon--tsx" aria-hidden="true"></span>
<span class="tree-label">Button.tsx</span>
</span>
</li>
<li role="treeitem" aria-level="3" aria-selected="false" class="tree-item tree-item--file" tabindex="-1">
<span class="tree-item-content">
<span class="tree-spacer" aria-hidden="true"></span>
<span class="tree-icon tree-icon--tsx" aria-hidden="true"></span>
<span class="tree-label">Modal.tsx</span>
</span>
</li>
<li role="treeitem" aria-level="3" aria-selected="false" class="tree-item tree-item--file" tabindex="-1">
<span class="tree-item-content">
<span class="tree-spacer" aria-hidden="true"></span>
<span class="tree-icon tree-icon--tsx" aria-hidden="true"></span>
<span class="tree-label">TreeView.tsx</span>
</span>
</li>
</ul>
</li>
<!-- src/hooks/ -->
<li role="treeitem" aria-expanded="false" aria-level="2" aria-selected="false" class="tree-item tree-item--folder" tabindex="-1">
<span class="tree-item-content">
<span class="tree-chevron" aria-hidden="true"></span>
<span class="tree-icon tree-icon--folder" aria-hidden="true"></span>
<span class="tree-label">hooks</span>
</span>
<ul role="group" class="tree-group" hidden>
<li role="treeitem" aria-level="3" aria-selected="false" class="tree-item tree-item--file" tabindex="-1">
<span class="tree-item-content">
<span class="tree-spacer" aria-hidden="true"></span>
<span class="tree-icon tree-icon--ts" aria-hidden="true"></span>
<span class="tree-label">useFocusTrap.ts</span>
</span>
</li>
<li role="treeitem" aria-level="3" aria-selected="false" class="tree-item tree-item--file" tabindex="-1">
<span class="tree-item-content">
<span class="tree-spacer" aria-hidden="true"></span>
<span class="tree-icon tree-icon--ts" aria-hidden="true"></span>
<span class="tree-label">useKeyboard.ts</span>
</span>
</li>
</ul>
</li>
<!-- src/styles/ -->
<li role="treeitem" aria-expanded="false" aria-level="2" aria-selected="false" class="tree-item tree-item--folder" tabindex="-1">
<span class="tree-item-content">
<span class="tree-chevron" aria-hidden="true"></span>
<span class="tree-icon tree-icon--folder" aria-hidden="true"></span>
<span class="tree-label">styles</span>
</span>
<ul role="group" class="tree-group" hidden>
<li role="treeitem" aria-level="3" aria-selected="false" class="tree-item tree-item--file" tabindex="-1">
<span class="tree-item-content">
<span class="tree-spacer" aria-hidden="true"></span>
<span class="tree-icon tree-icon--css" aria-hidden="true"></span>
<span class="tree-label">globals.css</span>
</span>
</li>
<li role="treeitem" aria-level="3" aria-selected="false" class="tree-item tree-item--file" tabindex="-1">
<span class="tree-item-content">
<span class="tree-spacer" aria-hidden="true"></span>
<span class="tree-icon tree-icon--css" aria-hidden="true"></span>
<span class="tree-label">tokens.css</span>
</span>
</li>
</ul>
</li>
<!-- src/index.ts -->
<li role="treeitem" aria-level="2" aria-selected="false" class="tree-item tree-item--file" tabindex="-1">
<span class="tree-item-content">
<span class="tree-spacer" aria-hidden="true"></span>
<span class="tree-icon tree-icon--ts" aria-hidden="true"></span>
<span class="tree-label">index.ts</span>
</span>
</li>
<!-- src/App.tsx -->
<li role="treeitem" aria-level="2" aria-selected="false" class="tree-item tree-item--file" tabindex="-1">
<span class="tree-item-content">
<span class="tree-spacer" aria-hidden="true"></span>
<span class="tree-icon tree-icon--tsx" aria-hidden="true"></span>
<span class="tree-label">App.tsx</span>
</span>
</li>
</ul>
</li>
<!-- public/ -->
<li role="treeitem" aria-expanded="false" aria-level="1" aria-selected="false" class="tree-item tree-item--folder" tabindex="-1">
<span class="tree-item-content">
<span class="tree-chevron" aria-hidden="true"></span>
<span class="tree-icon tree-icon--folder" aria-hidden="true"></span>
<span class="tree-label">public</span>
</span>
<ul role="group" class="tree-group" hidden>
<li role="treeitem" aria-level="2" aria-selected="false" class="tree-item tree-item--file" tabindex="-1">
<span class="tree-item-content">
<span class="tree-spacer" aria-hidden="true"></span>
<span class="tree-icon tree-icon--img" aria-hidden="true"></span>
<span class="tree-label">favicon.svg</span>
</span>
</li>
<li role="treeitem" aria-level="2" aria-selected="false" class="tree-item tree-item--file" tabindex="-1">
<span class="tree-item-content">
<span class="tree-spacer" aria-hidden="true"></span>
<span class="tree-icon tree-icon--file" aria-hidden="true"></span>
<span class="tree-label">robots.txt</span>
</span>
</li>
</ul>
</li>
<!-- Root files -->
<li role="treeitem" aria-level="1" aria-selected="false" class="tree-item tree-item--file" tabindex="-1">
<span class="tree-item-content">
<span class="tree-spacer" aria-hidden="true"></span>
<span class="tree-icon tree-icon--json" aria-hidden="true"></span>
<span class="tree-label">package.json</span>
</span>
</li>
<li role="treeitem" aria-level="1" aria-selected="false" class="tree-item tree-item--file" tabindex="-1">
<span class="tree-item-content">
<span class="tree-spacer" aria-hidden="true"></span>
<span class="tree-icon tree-icon--ts" aria-hidden="true"></span>
<span class="tree-label">tsconfig.json</span>
</span>
</li>
<li role="treeitem" aria-level="1" aria-selected="false" class="tree-item tree-item--file" tabindex="-1">
<span class="tree-item-content">
<span class="tree-spacer" aria-hidden="true"></span>
<span class="tree-icon tree-icon--file" aria-hidden="true"></span>
<span class="tree-label">README.md</span>
</span>
</li>
</ul>
</div>
<!-- Keyboard guide -->
<div class="info-panel">
<h3 class="info-title">Keyboard Navigation</h3>
<div class="kbd-grid">
<span class="kbd-item"><kbd>Up</kbd> / <kbd>Down</kbd> Move between visible items</span>
<span class="kbd-item"><kbd>Right</kbd> Expand folder or move to first child</span>
<span class="kbd-item"><kbd>Left</kbd> Collapse folder or move to parent</span>
<span class="kbd-item"><kbd>Home</kbd> / <kbd>End</kbd> First / last visible item</span>
<span class="kbd-item"><kbd>Enter</kbd> / <kbd>Space</kbd> Toggle selection</span>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>A file-system-style tree view with full keyboard navigation (Up/Down/Left/Right/Home/End/Enter/Space) and ARIA tree roles including role="tree", role="treeitem", role="group", and proper aria-expanded/aria-selected/aria-level state management.