component
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.
---
import { TreeView } from "@pindoba/astro-tree-view";
import type { TreeNodeInput } from "@pindoba/core-tree-view";
const items: TreeNodeInput[] = [
{
id: "src",
label: "src",
defaultExpanded: true,
children: [
{
id: "components",
label: "components",
children: [
{ id: "button", label: "Button.astro" },
{ id: "input", label: "Input.astro" },
],
},
{ id: "app", label: "app.ts" },
],
},
{
id: "tests",
label: "tests",
children: [{ id: "smoke", label: "smoke.test.ts" }],
},
{ id: "readme", label: "README.md" },
];
---
<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.
---
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: TreeNodeInput[] = [
{
id: "root",
label: "Root",
defaultExpanded: true,
children: [
{ id: "a", label: "Child A" },
{ id: "b", label: "Child B" },
],
},
];
const sizes: TreeViewSize[] = ["sm", "md", "lg"];
---
<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, feedback, 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".
---
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: TreeNodeInput[] = [
{
id: "src",
label: "src",
defaultExpanded: true,
children: [
{ id: "index", label: "index.ts" },
{ id: "types", label: "types.ts" },
],
},
{ id: "readme", label: "README.md" },
];
---
<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>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>.
---
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: TreeNodeInput[] = [
{
id: "docs",
label: "Documents",
defaultExpanded: true,
children: [
{ id: "resume", label: "resume.pdf" },
{ id: "notes", label: "notes.md" },
],
},
{ id: "todo", label: "todo.txt" },
];
---
<TreeView items={items} aria-label="Files with icons">
<Stamp slot="docs:leading" emphasis="ghost" size="sm"><Folder /></Stamp>
<Stamp slot="resume:leading" emphasis="ghost" size="sm"><FileText /></Stamp>
<Stamp slot="notes:leading" emphasis="ghost" size="sm"><FileText /></Stamp>
<Stamp slot="todo:leading" emphasis="ghost" size="sm"><FileText /></Stamp>
</TreeView>---
import { TreeView } from "@pindoba/astro-tree-view";
import type { TreeNodeInput } from "@pindoba/core-tree-view";
const items: TreeNodeInput[] = [
{
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" },
];
---
<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").
---
import { TreeView } from "@pindoba/astro-tree-view";
import type { TreeNodeInput } from "@pindoba/core-tree-view";
const items: TreeNodeInput[] = [
{
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" },
];
---
<TreeView
items={items}
checkboxes
selectionMode="multiple"
selectionBehavior="cascade"
defaultSelectedKeys={["orange"]}
aria-label="Select foods"
/>Use selectionBehavior="independent" for per-node checkboxes with no cascade.
---
import { TreeView } from "@pindoba/astro-tree-view";
import type { TreeNodeInput } from "@pindoba/core-tree-view";
const items: TreeNodeInput[] = [
{
id: "perms",
label: "Permissions",
defaultExpanded: true,
children: [
{ id: "read", label: "Read" },
{ id: "write", label: "Write" },
{ id: "admin", label: "Admin" },
],
},
];
---
<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.
---
import { TreeView } from "@pindoba/astro-tree-view";
import Button from "@pindoba/astro-button";
import { Pencil, Trash2 } from "@lucide/astro";
import type { TreeNodeInput } from "@pindoba/core-tree-view";
const items: TreeNodeInput[] = [
{
id: "team",
label: "Team",
defaultExpanded: true,
children: [
{ id: "ada", label: "Ada Lovelace" },
{ id: "alan", label: "Alan Turing" },
],
},
];
---
<TreeView items={items} aria-label="Team members">
<Fragment slot="ada:actions">
<Button emphasis="ghost" size="xs" shape="square" aria-label="Edit Ada">
<Pencil />
</Button>
<Button
emphasis="ghost"
size="xs"
shape="square"
feedback="danger"
aria-label="Delete Ada"
>
<Trash2 />
</Button>
</Fragment>
<Fragment slot="alan:actions">
<Button emphasis="ghost" size="xs" shape="square" aria-label="Edit Alan">
<Pencil />
</Button>
<Button
emphasis="ghost"
size="xs"
shape="square"
feedback="danger"
aria-label="Delete Alan"
>
<Trash2 />
</Button>
</Fragment>
</TreeView>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.
---
import { TreeView } from "@pindoba/astro-tree-view";
import type { TreeNodeInput } from "@pindoba/core-tree-view";
const items: TreeNodeInput[] = [
{
id: "a",
label: "Parent",
defaultExpanded: true,
children: [
{ id: "a1", label: "Child one" },
{
id: "a2",
label: "Child two",
defaultExpanded: true,
children: [{ id: "a2a", label: "Grandchild" }],
},
],
},
];
---
<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.Trailing action controls (sibling of the toggle, outside it).
Leading action controls (sibling of the toggle, outside it).
falseMark this as the chosen one in a set (a current nav item, a checked option, a selected card). Where `interactive` says it *can* be clicked, `active` says it *is* the current choice. Use `activeEmphasis` to control how loud the state is. Combined with `interactive`, hover and press restart their stepping from the active baseline rather than falling back to the unchosen ramp. The three values differ only in what drives the state: `true` by the prop (via `data-panel-active`), `"checked"` by a wrapped native input (`:has(input:checked)` — zero JS, nothing to re-render), and `"current"` by an existing `aria-current`.
the panel's `emphasis`How loud the `active` state is. Defaults to the panel's own `emphasis`, so the chosen state lands on the same ramp as the resting look — this prop is the override for when the active item should be louder (or quieter) than the panel itself. `"primary"` fills with the feedback accent ramp and flips text to the contrast scale (~150 RGB units of separation — the option that reads at a glance in a dense list). `"secondary"` tints the feedback surface ramp and `"tertiary"` the neutral ramp; both promote text to `bold` and lift the border to the default line, but move the surface only ~7–18 units.
"default"The container surface preset: `"default"` is the raised, padded card-like surface; `"subtle"` is flush (transparent, no padding, no shadow). It only sets Panel defaults — pass any Panel prop (`background`, `padding`, `radius`, `shadow`, `border`, …) alongside it to override that axis.
"ul"HTML element to render the tree container as.
Place the keyboard cursor on the first node on mount.
Surface background from the Panel scale (`surface.peak` → `surface.ground`, or `transparent`).
Border style: `none`, `default`, `bold`, or `muted`.
Border color while the surface is `active` — the explicit form of the ring `activeEmphasis` would otherwise pick. It holds through hover, so a `borderInteract` color can't steal it. `inherit` keeps whatever `activeEmphasis` chose; `none` clears it.
Border style applied on hover / focus interaction.
falseWhether to render a checkbox at the start of every row.
Initial expanded branches (uncontrolled).
Initial selection (uncontrolled).
Keys that cannot be selected or focused.
Bindable element reference for the root `role="tree"` container (a `<ul>` unless `as` says otherwise).
"secondary"The Panel emphasis ramp of the container surface. Passing `"default"` or `"subtle"` is a **deprecated** alias for {@link TreeViewBaseProps.appearance} and is mapped to it.
Bindable expanded branches.
Semantic color tone: `neutral`, `primary`, `success`, `warning`, `danger`, or `inherit`.
Bindable keyboard cursor (read-only — drive it via keyboard / pointer).
Enable interactive (hover / focus / press) affordances and states.
The tree's data — a nested array of nodes. A node is `{ id, label?, children?, disabled?, badge?, leading?, trailing? }`; nesting `children` builds the hierarchy.
Leading decoration per node (icon / Stamp). Receives the rendered item.
Fires when a node is activated (click / Enter).
Fires whenever the set of expanded branches changes.
Fires whenever the selection changes.
Inner padding from the spacing scale.
Per-slot escape hatch to inject Panda styles or HTML attributes into any slot of the component.
Corner radius override from the spacing scale (or `full` for fully rounded).
Corner radius for the bottom-left and bottom-right corners.
Corner radius for the top-left and bottom-left corners.
Corner radius for the top-right and bottom-right corners.
Corner radius for the top-left and top-right corners.
falseSerialize the panel's resolved surface inputs into a `data-panel-config` attribute on the root, enabling runtime surface changes via `updatePanelElement()` (and, for inheritors like Card, `updateCard()`) without a framework runtime — the primary consumer is Astro / vanilla JS, where props don't re-render. Reactive frameworks (Svelte/React/Vue) don't need this: their prop changes re-run the connect already.
Keep (or drop) the always-1px transparent border the panel reserves so a border appearing or changing never shifts layout. Computed automatically — it's reserved when a resting `border` is visible, or when `interactive` / `borderInteract` / `active` can change the border at runtime. Set `false` only on a panel that must not occupy that 1px (e.g. a borderless housing frame that would otherwise add 2px around a set).
Extra HTML attributes spread onto the root `tree` element.
Bindable current selection / checked keys.
"cascade"How a checkbox toggle propagates: `"cascade"` (parent ⇄ descendants, with indeterminate parents) or `"independent"` (each node alone).
"single"How clicks / Enter / Space resolve into a selection. `"none"` activates without persisting selection.
Elevation shadow applied to the surface.
trueWhether to draw the per-depth connecting guide rails.
"md"Visual size variant — controls font size, row height, and indent width.
Trailing decoration per node, before any actions.
Apply a frosted-glass effect with backdrop blur over a surface background.
Plus all standard <ul> HTML
attributes.