component

Tree View

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.

Default

Pass a nested items array; nodes with children become expandable branches. Click a branch (or press →) to expand it.

  • src
  • app.ts
  • README.md
---
// 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" />

Keyboard

The tree follows the WAI-ARIA tree keyboard model with roving focus:

  • ↑ / ↓ — move to the previous / next visible node.
  • → — expand a collapsed branch, or step into its first child.
  • ← — collapse an expanded branch, or step to the parent.
  • Home / End — jump to the first / last visible node.
  • Enter activates (selects); Space toggles the checkbox / selection. Type-ahead jumps to the next node whose label matches.

Sizes

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.

  • Root
  • Child A
  • Child B
  • Root
  • Child A
  • Child B
  • Root
  • Child A
  • Child B
---
// 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>

Surface

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 for appearance. Every other emphasis value 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.

  • src
  • index.ts
  • types.ts
  • README.md

Default radius.

  • src
  • index.ts
  • types.ts
  • README.md

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>

Card

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.

  • A rendered heading labels the tree (aria-labelledby) and a subheading describes it (aria-describedby), unless you pass aria-label / aria-labelledby yourself.
  • Your own attributes (id, aria-*, data-*) stay on the <ul role="tree">, not the card root.
  • appearance="subtle" renders bare unless card is an object.

Runbooks

Step-by-step fixes for on-call engineers

  • Databases
  • Fail over to the replica
  • Restore from a snapshot
  • Check table bloat
  • Drain stuck webhooks
6 runbooks · reviewed every quarter
---
// 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. A padding outside the card size scale is ignored in card mode — use card={false} if you need it, or card.size / card.content.padding.

Icons and badges

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>.

  • Documents
  • resume.pdf
  • notes.md
  • todo.txt
---
// 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>
  • Inbox12
  • Work8
  • Personal4
  • Archive
---
// 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" />

Checkboxes (cascading)

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").

  • Fruit
  • Apple
  • Citrus
  • Orange
  • Lemon
  • Grain

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>

Checkboxes (independent)

Use selectionBehavior="independent" for per-node checkboxes with no cascade.

  • Permissions
  • Read
  • Write
  • Admin
---
// 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"
/>

Trailing actions

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.

  • Team
  • Ada Lovelace
  • Alan Turing

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>

Guide rails

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.

  • Parent
  • Child one
  • Child two
  • Grandchild
---
// 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" />

Accessibility

  • Implements the WAI-ARIA tree pattern: role="tree" on the container, role="treeitem" per row, with aria-level, aria-setsize, aria-posinset, and aria-expanded on branches.
  • Roving tabindex keeps a single tab stop; arrow keys move the cursor and DOM focus together.
  • Selection state is announced through aria-selected, or aria-checked (including the mixed tri-state) when checkboxes is enabled.
  • Trailing/leading action controls are siblings of the toggle, never nested inside an interactive element.
  • Components
  • Blocks