---
title: "JavaScript Tree View"
description: "The LemonadeJS Tree View block for JavaScript: Collapsible tree with selection. Contract-verified, framework-agnostic, zero dependencies, with a live example."
source: https://lemonadejs.com/docs/plugins/treeview/
---

<link rel="stylesheet" href="/v6/treeview.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined">

# JavaScript Tree View

`@lemonadejs/treeview` · ✓ 10 contract checks · framework-agnostic · zero dependencies

.

A hierarchical list rendered by ONE recursive view function: nodeView
calls itself for node.children, every repeated `<li>` is keyed by the
node id, and the whole tree hangs off a single live expression reading
the data state. Mutate the tree in place + data.touch() (or assign a
new array) and the keyed diff moves/keeps existing DOM instead of
rebuilding it — including NESTED sibling lists, which the engine
compares structurally.

Collapse keeps the child DOM ALIVE: visibility is the aria-expanded
attribute + CSS (display: none on the group), never an unmount. Same
choice as `<Tabs />` panels. Rationale: re-expanding is instant, child DOM
identity (and anything the host stuffed into it) survives toggles, the
accessibility attribute IS the rendering switch (one source of truth),
and toggling never re-runs the keyed diff. For huge lazy trees an
unmounting branch would be the alternative; for a block-sized tree
keep-alive is the idiomatic v6 answer.

Contract:
  data        TreeNode[]: { id, label, icon?, open?, children? }
  bind        two-way selected node id (bind="${state}")
  draggable   opt-in drag-and-drop reordering (default false)
  onchange    (id, node) — fires on user/api selection (parent writes
              to the bound state never echo back)
  ontoggle    (id, open) — fires on expand/collapse (user or api)
  onmove      (id, parentId, index) — after a drag drops a node into a
              new position (parentId is null at the root)
  api         { open(id), close(id), select(id), toggle(id) }

Drag-and-drop (draggable only): a pointer gesture (3px threshold,
Escape cancels, a drag never fires the click selection) with ZERO
LAYOUT SHIFT — a dense list must not reflow under the cursor. The
origin row stays in the flow, dimmed; a small chip with the node's
label rides the cursor; the landing slot is an absolutely positioned
insertion line (before/after) or a ring on the container row
(inside). Hovering a CONTAINER row (a node with a children
array — children: [] is an empty folder) splits it in thirds: top →
drop BEFORE (sibling), bottom → drop AFTER (sibling), middle → drop
INSIDE (becomes a child, opening the target). A LEAF row (no children
key) splits in halves, before/after only — a leaf never becomes a
parent via drop. The tree array is mutated in place + data.touch(),
so the keyed diff MOVES existing DOM instead of rebuilding it. A node
can never drop onto itself or into its own subtree.

Keyboard (APG tree pattern, single select):
  ArrowRight  opens a closed parent; on an open parent moves to the
              first child
  ArrowLeft   closes an open parent; otherwise moves to the parent
  ArrowUp/Down move focus across VISIBLE nodes
  Enter       selects the focused node

Focus lands on the <li role="treeitem"> ITSELF, so the focused element
is the one carrying aria-expanded/aria-selected (WCAG 4.1.2), and the
tree exposes a ROVING tabindex (WCAG 2.4.3): exactly one visible node
— the last focused, else the selected, else the first — is
tabindex="0"; every other node is tabindex="-1", so Tab enters and
leaves the tree in one stop.

## Example

<!--example-->

```js
import { html } from 'lemonadejs';
import TreeView from '@lemonadejs/treeview';

const files = [
    { id: 'src', label: 'src', open: true, children: [
        { id: 'index', label: 'index.js' },
        { id: 'style', label: 'style.css' },
        { id: 'utils', label: 'utils', children: [
            { id: 'walk', label: 'walk.js' },
            { id: 'keys', label: 'keys.js' },
        ] },
    ] },
    { id: 'docs', label: 'docs', children: [{ id: 'readme', label: 'README.md' }] },
    { id: 'pkg', label: 'package.json' },
];

const App = (props, { state }) => {
    const selected = state('index');

    return html`<div>
        <${TreeView} data="${files}" bind="${selected}" draggable />
        <p style="font-size:13px">Selected: <b>${selected}</b> — arrows move, Enter selects, drag to reorder.</p>
    </div>`;
};
```

## Installation

```bash
npm install @lemonadejs/treeview
```

```js
import Treeview from '@lemonadejs/treeview';
import '@lemonadejs/treeview/style.css';
```

The icons come from Google Material Symbols. Load the font once per page:

```html
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined">
```

Three deployment forms, one component:

```js
html`<${Treeview} />`                       // by value (no registration)
setComponents({ Treeview });               // then <Treeview /> by name anywhere
createWebComponent(Treeview);              // <lm-treeview> in plain HTML/any framework
```

## Props

Every declared prop arrives as a **live state** — pass a value for a snapshot or a
state for a two-way live wire. Attribute strings are coerced to the declared type.

| Prop | Type | Default | Description |
|---|---|---|---|
| `bind` | any | — | Two-way bound value. `.set()` fires `onchange`; plain assignment is silent. two-way selected node id — 'any': ids are string | number |
| `data` | array | — | TreeNode[] — the tree |
| `draggable` | boolean | `false` | opt-in drag-and-drop reordering |

## Events

All event names are lowercase (the platform convention — LJS-305 warns otherwise).

- `onchange` — (id, node) on selection
- `ontoggle` — (id, open) on expand/collapse
- `onmove` — (id, parentId, index) after a drag drop

## API

```js
import { ref } from 'lemonadejs';
const treeview = ref();
html`<${Treeview} ref="${treeview}" />`;
// treeview.current.open(...)  ·  treeview.current.close(...)  ·  treeview.current.select(...)  ·  treeview.current.toggle(...)
```

- `open()`
- `close()`
- `select()`
- `toggle()`

## Styling

All classes follow the `lm-treeview-*` convention; visual variants are `data-*`
attributes on the root. Override freely — there is no styling engine to fight.

## Contract

The machine-readable schema ships with the package:

```js
import contract from '@lemonadejs/treeview/contract.json';
```

`verify.json` carries the conformance proof produced by `verify(Treeview)`.

Looking for the v5 plugin? See the [archived v5 documentation](/docs/v5/plugins/).