An accessible, keyboard-navigable tree for hierarchical data. It implements the
WAI-ARIA tree pattern (role="tree" / treeitem, roving focus, aria-level),
supports optional checkboxes with cascading or independent selection, draws
continuous per-depth guide rails, and composes leading icons, trailing badges,
and trailing action controls. The same framework-agnostic core drives the Astro,
Svelte, and React implementations.
Pass a nested items array; nodes with children become expandable branches.
Click a branch (or press →) to expand it.
---
// GENERATED from packages/demos/tree-view/default.demo.tsx — edit the source, not this file.
import { TreeView } from "@pindoba/astro-tree-view";
import type { TreeNodeInput } from "@pindoba/core-tree-view";
const items = [
{
id: "src",
label: "src",
defaultExpanded: true,
children: [
{
id: "components",
label: "components",
children: [
{ id: "button", label: "Button.svelte" },
{ id: "input", label: "Input.svelte" },
],
},
{ id: "app", label: "app.ts" },
],
},
{
id: "tests",
label: "tests",
children: [{ id: "smoke", label: "smoke.test.ts" }],
},
{ id: "readme", label: "README.md" },
] as TreeNodeInput[];
---
<TreeView items={items} aria-label="Project files" />The tree follows the WAI-ARIA tree keyboard model with roving focus:
Tree view supports sm, md (default), and lg — row height follows the
Control Var Contract, so a tree row matches a same-size Button or Input.
---
// GENERATED from packages/demos/tree-view/sizes.demo.tsx — edit the source, not this file.
import { TreeView } from "@pindoba/astro-tree-view";
import { stack } from "@pindoba/styled-system/patterns";
import type { TreeNodeInput, TreeViewSize } from "@pindoba/core-tree-view";
const items = [
{
id: "root",
label: "Root",
defaultExpanded: true,
children: [
{ id: "a", label: "Child A" },
{ id: "b", label: "Child B" },
],
},
] as TreeNodeInput[];
const sizes = ["sm", "md", "lg"] as TreeViewSize[];
---
<div class={stack({ gap: "md" })}>
{sizes.map((size) => (
<TreeView items={items} size={size} aria-label={`Tree ${size}`} />
))}
</div>The tree container is a Panel root, so it takes every Panel surface prop —
background, tone, padding, radius (including radius="inner" for a
concentric corner inside a Card or Dialog), border, shadow, translucent,
and as.
appearance is the shorthand that presets them: "default" (the raised,
padded surface) or "subtle" (flush — transparent, unpadded, no shadow). Any
Panel prop passed alongside it overrides just that axis.
<TreeView appearance="subtle" items={items} />
<TreeView radius="inner" padding="xs" items={items} />
emphasis="default"/emphasis="subtle"still work as a deprecated alias forappearance. Every otheremphasisvalue is the Panel emphasis ramp ("primary" | "secondary" | "tertiary", defaulting to"secondary").
Nested inside a Card, radius="inner" keeps the tree’s corner concentric with
the Card’s — it resolves as the Card’s radius minus its content padding, so
the two curves stay in step whatever the Card’s radius and size are.
The tree container is a Panel root, so the default appearance surface takes radius="inner" and curves concentrically with the Card around it.
Default radius.
radius="inner".
---
// GENERATED from packages/demos/tree-view/inner-radius.demo.tsx — edit the source, not this file.
import { TreeView } from "@pindoba/astro-tree-view";
import Card from "@pindoba/astro-card";
import { css } from "@pindoba/styled-system/css";
import { stack, grid } from "@pindoba/styled-system/patterns";
import type { TreeNodeInput } from "@pindoba/core-tree-view";
const items = [
{
id: "src",
label: "src",
defaultExpanded: true,
children: [
{ id: "index", label: "index.ts" },
{ id: "types", label: "types.ts" },
],
},
{ id: "readme", label: "README.md" },
] as TreeNodeInput[];
---
<div class={stack({ gap: "md", direction: "column", width: "full" })}>
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
The tree container is a Panel root, so the default <code>appearance</code>{" "}
surface takes <code>radius="inner"</code> and curves concentrically with the
Card around it.
</p>
<div class={grid({ columns: 2, gap: "lg" })}>
<Card size="md" radius="2xl" border="default">
<TreeView items={items} aria-label="Files, default radius" />
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
Default radius.
</p>
</Card>
<Card size="md" radius="2xl" border="default">
<TreeView items={items} radius="inner" aria-label="Files, inner radius" />
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
<code>radius="inner"</code>.
</p>
</Card>
</div>
</div>With appearance="default" the tree is card-based:
it renders inside its own Card, and the role="tree" list is the card’s
content. Pass Card props through card — heading, subheading, leading,
trailing, footer, background, contentBackground… With no card props it
looks like the old raised container: the tree-level surface props
(background, shadow, radius, border, tone, emphasis,
translucent) become the card’s defaults.
aria-labelledby) and a subheading
describes it (aria-describedby), unless you pass aria-label /
aria-labelledby yourself.id, aria-*, data-*) stay on the <ul role="tree">,
not the card root.appearance="subtle" renders bare unless card is an object.---
// GENERATED from packages/demos/tree-view/card-inset.demo.tsx — edit the source, not this file.
import { TreeView } from "@pindoba/astro-tree-view";
import Stamp from "@pindoba/astro-stamp";
import { Folder, FileText, BookOpen } from "@lucide/astro";
import type { TreeNodeInput } from "@pindoba/core-tree-view";
const items = [
{
id: "databases",
label: "Databases",
defaultExpanded: true,
children: [
{ id: "failover", label: "Fail over to the replica" },
{ id: "restore", label: "Restore from a snapshot" },
{ id: "bloat", label: "Check table bloat" },
],
},
{
id: "networking",
label: "Networking",
children: [
{ id: "dns", label: "DNS change not propagating" },
{ id: "tls", label: "Renew a TLS certificate" },
],
},
{ id: "webhooks", label: "Drain stuck webhooks" },
] as TreeNodeInput[];
const created = 0;
const flattenTreeItems = <T extends { children?: T[] }>(
nodes: readonly T[],
): (T & { hasChildren: boolean })[] =>
nodes.flatMap((node) => [
{ ...node, hasChildren: (node.children?.length ?? 0) > 0 },
...flattenTreeItems(node.children ?? []),
]);
---
<template data-ir-root="tree-view/card-inset"></template>
<TreeView
items={items}
card={{
heading: "Runbooks",
subheading: "Step-by-step fixes for on-call engineers",
background: "surface.base",
contentBackground: "surface.peak",
border: "default",
footer: { leading: "6 runbooks · reviewed every quarter" },
}}
data-ir="1"
>
<Stamp slot="card-leading" emphasis="ghost" size="md">
<BookOpen />
</Stamp>
{flattenTreeItems(items).map((item) => (
<Fragment slot={`${item.id}:leading`}>
<Stamp emphasis="ghost" size="sm">
{item.hasChildren ? (
<>
<Folder />
</>
) : (
<>
<FileText />
</>
)}
</Stamp>
</Fragment>
))}
</TreeView>
<!--ir:3-->
{created > 0 && (
<p>
Drafts started: <!--ir:4-->
{created}
<!--/ir-->
</p>
)}
<!--/ir-->
<template data-ir-root="tree-view/card-inset"></template>
<script>
import { hydrate } from "./_client/card-inset.ts";
const roots = [
...document.querySelectorAll('[data-ir-root="tree-view/card-inset"]'),
];
for (let i = 0; i < roots.length; i += 2) hydrate(roots.slice(i, i + 2));
</script>card={false} opts out: the tree list is the root Panel again, exactly the
pre-card DOM.
<TreeView {items} card={false} aria-label="Files" />
Migrating from the pre-card tree: the default root is now a Card
<div>([data-component="card"][data-card-kind="tree-view"]) wrapping the<ul role="tree">, where the list used to be the root. Apaddingoutside the card size scale is ignored in card mode — usecard={false}if you need it, orcard.size/card.content.padding.
Each node accepts a badge string (rendered through a <Badge>) and a leading
decoration via the <id>:leading slot (Astro) / leading snippet (Svelte) /
leading render prop (React) — typically a <Stamp>.
---
// GENERATED from packages/demos/tree-view/with-icons.demo.tsx — edit the source, not this file.
import { TreeView } from "@pindoba/astro-tree-view";
import Stamp from "@pindoba/astro-stamp";
import { Folder, FileText } from "@lucide/astro";
import type { TreeNodeInput } from "@pindoba/core-tree-view";
const items = [
{
id: "docs",
label: "Documents",
defaultExpanded: true,
children: [
{ id: "resume", label: "resume.pdf" },
{ id: "notes", label: "notes.md" },
],
},
{ id: "todo", label: "todo.txt" },
] as TreeNodeInput[];
const flattenTreeItems = <T extends { children?: T[] }>(
nodes: readonly T[],
): (T & { hasChildren: boolean })[] =>
nodes.flatMap((node) => [
{ ...node, hasChildren: (node.children?.length ?? 0) > 0 },
...flattenTreeItems(node.children ?? []),
]);
---
<TreeView items={items} aria-label="Files with icons">
{flattenTreeItems(items).map((item) => (
<Fragment slot={`${item.id}:leading`}>
<Stamp emphasis="ghost" size="sm">
{item.hasChildren ? (
<>
<Folder />
</>
) : (
<>
<FileText />
</>
)}
</Stamp>
</Fragment>
))}
</TreeView>---
// GENERATED from packages/demos/tree-view/with-badges.demo.tsx — edit the source, not this file.
import { TreeView } from "@pindoba/astro-tree-view";
import type { TreeNodeInput } from "@pindoba/core-tree-view";
const items = [
{
id: "inbox",
label: "Inbox",
badge: "12",
defaultExpanded: true,
children: [
{ id: "work", label: "Work", badge: "8" },
{ id: "personal", label: "Personal", badge: "4" },
],
},
{ id: "archive", label: "Archive" },
] as TreeNodeInput[];
---
<TreeView items={items} aria-label="Mailboxes" />Enable checkboxes to render a checkbox per row. With selectionMode="multiple"
and selectionBehavior="cascade" (the default), checking a branch checks every
descendant and a partially-checked branch renders indeterminate
(aria-checked="mixed").
Selected: orange
---
// GENERATED from packages/demos/tree-view/checkboxes-cascade.demo.tsx — edit the source, not this file.
import { TreeView } from "@pindoba/astro-tree-view";
import type { Key, TreeNodeInput } from "@pindoba/core-tree-view";
const items = [
{
id: "fruit",
label: "Fruit",
defaultExpanded: true,
children: [
{ id: "apple", label: "Apple" },
{
id: "citrus",
label: "Citrus",
defaultExpanded: true,
children: [
{ id: "orange", label: "Orange" },
{ id: "lemon", label: "Lemon" },
],
},
],
},
{ id: "grain", label: "Grain" },
] as TreeNodeInput[];
const selected = new Set<Key>(["orange"]);
---
<TreeView
items={items}
checkboxes
selectionMode="multiple"
selectionBehavior="cascade"
defaultValue={["orange"]}
aria-label="Select foods"
data-ir-root="tree-view/checkboxes-cascade"
data-ir="1"
/>
<p data-ir-root="tree-view/checkboxes-cascade">
Selected: <!--ir:2-->
{[...selected].join(", ") || "none"}
<!--/ir-->
</p>
<script>
import { hydrate } from "./_client/checkboxes-cascade.ts";
const roots = [
...document.querySelectorAll(
'[data-ir-root="tree-view/checkboxes-cascade"]',
),
];
for (let i = 0; i < roots.length; i += 2) hydrate(roots.slice(i, i + 2));
</script>Use selectionBehavior="independent" for per-node checkboxes with no cascade.
---
// GENERATED from packages/demos/tree-view/checkboxes-independent.demo.tsx — edit the source, not this file.
import { TreeView } from "@pindoba/astro-tree-view";
import type { TreeNodeInput } from "@pindoba/core-tree-view";
const items = [
{
id: "perms",
label: "Permissions",
defaultExpanded: true,
children: [
{ id: "read", label: "Read" },
{ id: "write", label: "Write" },
{ id: "admin", label: "Admin" },
],
},
] as TreeNodeInput[];
---
<TreeView
items={items}
checkboxes
selectionMode="multiple"
selectionBehavior="independent"
aria-label="Permissions"
/>Place interactive controls in the row’s trailing actions slot (<id>:actions in
Astro, the actions snippet/render prop in Svelte/React). They render as
siblings of the toggle — never nested inside it — so they’re independently
focusable, and clicking one never expands or selects the row.
Last action: none
---
// GENERATED from packages/demos/tree-view/trailing-actions.demo.tsx — edit the source, not this file.
import { TreeView } from "@pindoba/astro-tree-view";
import type { TreeNodeInput } from "@pindoba/core-tree-view";
const items = [
{
id: "team",
label: "Team",
defaultExpanded: true,
children: [
{ id: "ada", label: "Ada Lovelace" },
{ id: "alan", label: "Alan Turing" },
],
},
] as TreeNodeInput[];
const lastAction = "";
---
<TreeView
items={items}
aria-label="Team members"
data-ir-root="tree-view/trailing-actions"
data-ir="1"
/>
<p data-ir-root="tree-view/trailing-actions">
Last action: <!--ir:4-->
{lastAction || "none"}
<!--/ir-->
</p>
<script>
import { hydrate } from "./_client/trailing-actions.ts";
const roots = [
...document.querySelectorAll('[data-ir-root="tree-view/trailing-actions"]'),
];
for (let i = 0; i < roots.length; i += 2) hydrate(roots.slice(i, i + 2));
</script>Continuous per-depth guide rails connect each branch to its children, with the
active branch’s rails highlighted. Set showGuides={false} to drop them and
indent with whitespace alone.
---
// GENERATED from packages/demos/tree-view/no-guides.demo.tsx — edit the source, not this file.
import { TreeView } from "@pindoba/astro-tree-view";
import type { TreeNodeInput } from "@pindoba/core-tree-view";
const items = [
{
id: "a",
label: "Parent",
defaultExpanded: true,
children: [
{ id: "a1", label: "Child one" },
{
id: "a2",
label: "Child two",
defaultExpanded: true,
children: [{ id: "a2a", label: "Grandchild" }],
},
],
},
] as TreeNodeInput[];
---
<TreeView items={items} showGuides={false} aria-label="No guide rails" />tree pattern: role="tree" on the container,
role="treeitem" per row, with aria-level, aria-setsize, aria-posinset,
and aria-expanded on branches.tabindex keeps a single tab stop; arrow keys move the cursor and DOM
focus together.aria-selected, or aria-checked
(including the mixed tri-state) when checkboxes is enabled.