Tree View / Table
Free8 premium variants: collapsible tree, file explorer, tri-state checkboxes, org chart, tree table, searchable, drag & drop reordering and lazy load. Lazy loading, the tree-table columns and drag & drop reordering are Pro (`sv-tree-view-pro`); the free component renders the nodes you give it.
Complexity tier: Application The interactive bar, plus a stated data-volume ceiling, a persistence contract, a bundle budget and an accessible parity surface.
import { SvTreeViewComponent } from '@styloviz/tree-view';Demo & usage
Basic usage
Usage example — open the gallery for the full interactive demo.
An interactive demo for this component lives in the gallery.
Open live demoMore examples
Selectable with guide lines
<sv-tree-view [nodes]="nodes" [selectable]="true" [showLines]="true" (nodeSelect)="onSelect($event)" />
Saving a move, and putting it back (Pro)
<sv-tree-view-pro variant="draggable" [nodes]="nodes()" [savingMove]="saving()" [moveError]="error()" (nodeMoved)="onMove($event)" />
A lazy branch that fails, or comes back empty (Pro)
<sv-tree-view-pro variant="lazy" [nodes]="nodes()" [loadingNodeIds]="loading()" [nodeErrors]="errors()" emptyBranchText="No members" (lazyLoad)="load($event)" /> // errors() is a Map of node id to a message or an SvDataError. The id-only // [errorNodeIds] shorthand still marks a branch failed, but it cannot say why, // so the branch reads as one with no children. errors = signal(new Map<string, string>([['team', 'Request timed out']]));
When to use
- Browsing hierarchical data: file systems, categories, org charts, nested menus.
- You need expand/collapse. Tri-state checkbox selection and lazy loading of branches are Pro — the free component is a single-select tree and has no checked state at all.
- Free renders every node you pass, so give it a set you are happy to hold in memory — for a very large tree (tens of thousands of nodes) you want Pro’s `lazyLoad`.
- The size contract, stated rather than left to be discovered: neither component virtualizes, so every VISIBLE row is a DOM node. The flatten is not the limit — 200,000 rows flatten and search without throwing, and that is a spec — but the render is. Keep visible rows in the low thousands and lazy-load or collapse below that. A wide branch used to fail hardest: the flatten spread each level into the result with `push(...children)`, which is one argument per row and a `RangeError` past the engine’s cap (~65k in JavaScriptCore, ~124k in V8), so a real folder tree threw where a demo fixture never would.
- For picking one path from a hierarchy in a form, use the Cascader.
Async states
- loading
- empty
- error
- retry
Item identity & owned state
- Restorable state
- expandedIds
Bind these to put a saved view back — the open sections, the hidden columns, the selected rows — and read their
Changeevents to store what the user does.
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| nodes | TreeNode[] | [] | Tree node data (nested children, optional icons). |
| selectable | boolean | true | Enable single-row selection highlight. |
| showLines | boolean | true | Show vertical connector guide lines. |
| ariaLabel | string | '' | What this tree is, e.g. `'Project files'` — names the `role="tree"` region. Two trees side by side both announce as an unnamed "tree" without this. Left unset, no name is invented: an `aria-label` of "Tree" only repeats the role. Matches `sv-tree-view-pro`'s input of the same name. |
| loading | boolean | false | True while the tree is being fetched. Replaces it with a placeholder. |
| error | SvDataError | string | null | null | Load failure. Use `toSvDataError()` from `@styloviz/core` to normalize an `HttpErrorResponse`. |
| emptyText | string | 'No items to display' | Copy for the empty state. |
| expandedIds | Set<string> (two-way) | new Set() | Expanded node ids. Two-way bindable, so an expansion state loaded from the server — or restored from a URL — survives a reload. |
| selectedId | string | null (two-way) | null | The selected node id. Two-way bindable. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| nodeClick | TreeNodeClickEvent | Fires when an enabled node is clicked or activated with Enter/Space while `selectable`; payload is the node and its depth (0 for roots). |
| nodeExpand | TreeNode | Fires with the node when an enabled parent is expanded, by its chevron or ArrowRight. |
| nodeCollapse | TreeNode | Fires with the node when an expanded parent is collapsed, by its chevron or ArrowLeft. |
| nodeSelect | TreeNode | Fires with the node when it becomes selected by click or Enter/Space; never fires while `selectable` is false. |
| retry | void | The host should re-run the failed load. |
| expandedIdsChange | Set<string> | Emits the new `expandedIds` whenever it changes — the output half of `[(expandedIds)]`. |
| selectedIdChange | string | null | Emits the new `selectedId` whenever it changes — the output half of `[(selectedId)]`. |
<sv-tree-view-pro>
Also in this family — SvTreeViewProComponent from @styloviz-pro/tree-view.
Async states
- loading
- empty
- error
- retry
Item identity & owned state
- Restorable state
- checkedIds
- errorNodeIds
- expandedIds
- loadingNodeIds
Bind these to put a saved view back — the open sections, the hidden columns, the selected rows — and read their
Changeevents to store what the user does.
Server writes
- Writes
- nodeMoved
Each payload carries a
requestKey— send it with the request so a retry cannot apply the same change twice.- Rollback
- Call
event.rollback()when the save fails — the change is already on screen, and this is what puts it back. - While saving
- Bind
savingMove— with two writes outstanding there is no single state to roll back to, so the action stays locked until the first one lands. - On failure
- Pass the failure to
moveErrorto show it in place — without taking away what the user was working on.
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| nodes | TreeNode[] | [] | Tree node data. |
| variant | TreeVariant | 'default' | Rendering variant. |
| columns | TreeColumn[] | [] | Column definitions — required for `tree-table` variant. |
| selectable | boolean | true | Enable single-row selection highlight. |
| showLines | boolean | true | Show vertical connector guide lines. |
| size | TreeSize | 'md' | Component size preset. |
| emptyText | string | 'No items to display' | Label shown when there are no nodes or no search results. |
| customClass | string | '' | Additional CSS classes on the host container. |
| ariaLabel | string | '' | What this tree is, e.g. `'Project files'` — names the `role="tree"` region and captions the `tree-table` variant's table. Two trees side by side (a folder pane and a permissions pane is the ordinary dashboard) both announce as an unnamed "tree" without this, and a listener has no way to tell which one they landed in. Left unset, no name is invented — an `aria-label` of "Tree" only repeats the role. |
| loading | boolean | false | True while the root nodes are being fetched. Replaces the tree with a placeholder. For a single branch loading its children, use `loadingNodeIds` instead — blanking the whole tree to expand one folder would lose the user's place. |
| error | SvDataError | string | null | null | Load failure for the root nodes. Use `toSvDataError()` from `@styloviz/core` to normalize an `HttpErrorResponse`. |
| loadingNodeIds | readonly string[] | ReadonlySet<string> | [] | Ids of nodes whose children are being fetched. Each shows an inline spinner. Supply this from the host: the component cannot know when a `lazyLoad` you are handling has finished. |
| errorNodeIds | readonly string[] | ReadonlySet<string> | [] | Ids of nodes whose children failed to load. Each shows an inline retry control that re-emits `lazyLoad` for that node. This is the shorthand. Use `nodeErrors` instead when you can say *why* — a red retry arrow and nothing else is why a failed branch was indistinguishable from an empty one. |
| nodeErrors | ReadonlyMap<string, SvDataError | string> | null | null | Why each branch failed, keyed by node id. Rendered on the row beside the label, so the failure is legible and not only announced. A node here is failed whether or not it is also in `errorNodeIds`; pass one or the other. Values accept `SvDataError` or a plain string, the same as `error`. |
| emptyBranchText | string | 'Empty' | What an expanded branch says when its load came back with no children. |
| serverSide | boolean | false | Search on the server instead of in the component. The supplied `nodes` are rendered as given — filtering them again would hide branches the server deliberately returned. |
| debounceMs | number | 300 | Delay before `searchChange` fires, in milliseconds. `0` emits immediately. |
| savingMove | boolean | false | True while a `nodeMoved` is being saved. Freezes dragging until it settles. This is what makes `rollback()` unambiguous: with two moves in flight there is no single "previous tree" to go back to, so the second failure would restore a shape that discards the first move as well. |
| moveError | SvDataError | string | null | null | A `nodeMoved` that could not be saved. Reported above the tree, because the node has already been put back and there is no row left to attach it to. Separate from `error`, which replaces the whole tree: a failed move leaves a perfectly good tree on screen and blanking it would lose the user's place. |
| demoMode | boolean | false | Fabricate children 1.2 s after a lazy expand, for demos with no backend. Ignored entirely — and it must be — when you handle `lazyLoad` yourself: before this flag existed the component injected its own fake children on top of whatever the host loaded. |
| expandedIds | Set<string> (two-way) | new Set() | Expanded node ids. Two-way bindable, so an expansion state loaded from the server — or restored from a URL — survives a reload. |
| selectedId | string | null (two-way) | null | The selected node id. Two-way bindable. |
| checkedIds | Set<string> (two-way) | new Set() | Checked node ids. Two-way bindable, so a saved selection can be restored. |
| searchQuery | string (two-way) | '' | Search query. Two-way bindable, and bound via ngModel in the searchable variant. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| nodeClick | TreeNodeClickEvent | Emitted when any node row is clicked. |
| nodeExpand | TreeNode | Emitted when a node is expanded. |
| nodeCollapse | TreeNode | Emitted when a node is collapsed. |
| nodeSelect | TreeNode | Emitted when a node row is selected (selectable=true). |
| checkedChange | string[] | Emitted when checkbox state changes; payload is array of all checked IDs. |
| nodeMoved | TreeNodeMoveEvent | A node was dragged somewhere new. Persist it, and call `rollback()` if that fails. Emitted after the tree has moved it, and only if it really moved. |
| lazyLoad | TreeNode | Emitted when a lazy node is expanded for the first time. |
| searchChange | string | The search term changed — fetch matching nodes. Debounced by `debounceMs` and never emitted twice for the same term. Pair with `serverSide`. |
| retry | void | The host should re-run the failed root load. |
| expandedIdsChange | Set<string> | Emits the new `expandedIds` whenever it changes — the output half of `[(expandedIds)]`. |
| selectedIdChange | string | null | Emits the new `selectedId` whenever it changes — the output half of `[(selectedId)]`. |
| checkedIdsChange | Set<string> | Emits the new `checkedIds` whenever it changes — the output half of `[(checkedIds)]`. |
| searchQueryChange | string | Emits the new `searchQuery` whenever it changes — the output half of `[(searchQuery)]`. |
Wire it to your API
This component is headless about transport: it renders what you give it and reports what the user asks for. Nothing below requires @styloviz/data — drive it from whatever you already use.
Template
Bound to the names this component actually exposes.
<sv-tree-view [nodes]="data.items()" [loading]="data.loading()" [error]="data.error()" (retry)="data.reload()" />
Host class
Drive the three async inputs from your own fetch.
// This component reports its state but does not emit a query, so drive // it from your own fetch and bind the three async inputs. readonly items = signal<Row[]>([]); readonly loading = signal(false); readonly error = signal<SvDataError | null>(null); async load(): Promise<void> { this.loading.set(true); this.error.set(null); try { this.items.set(await this.api.fetchRows()); } catch (e) { this.error.set(toSvDataError(e)); } finally { this.loading.set(false); } }
- This component does not emit a query, so it renders what you give it. Fetch on your own trigger and bind `nodes`, `loading` and `error`.
- Normalize failures with `toSvDataError()` from `@styloviz/core`. It derives `retryable` from the status, so a 404 does not get a "Try again" button that can only fail again.
Full walkthrough: Connecting to a backend.
Customization & theming
- Tree, tree-table or org-chart presentations.
- Tri-state selection and lazy loading (both Pro, `variant="checkbox"` / `variant="lazy"`).
- **Selection propagation, stated rather than left to be discovered.** Down: ticking a branch ticks every **enabled** descendant; a disabled node and its subtree keep the state they had, in both directions. Up: a branch is ticked once every enabled child is, `indeterminate` while only some are, and a branch whose children are all disabled behaves as a leaf — its box stands for the branch alone. A ticked branch also cascades to children that arrive later, so a lazy folder ticked before its load still means "everything in here" once the files show up, and `checkedChange` re-fires with them.
- Drag & drop reordering with custom columns (Pro).
- Keyboard reordering on the Pro draggable variant: Ctrl/Cmd + ↑/↓ reorder among siblings, Ctrl/Cmd + → indents under the sibling above, Ctrl/Cmd + ← lifts the node out to after its parent. Both inputs go through one apply path, so `(nodeMoved)` carries the same `requestKey` and `rollback()` either way.
- Per-branch async state on the Pro lazy variant: `loadingNodeIds`, `nodeErrors` and `emptyBranchText` describe one branch each, independently of the whole-tree `loading`/`error`.
Best practices
- Do: Lazy-load deep branches so the initial render stays fast — `lazyLoad` on `sv-tree-view-pro`. On free, pass a pre-trimmed set instead.
- Do: Show guide lines (`showLines`) for deeply nested trees to aid scanning.
- Tip: Persist expanded state so navigation survives route changes.
- Do: On the Pro draggable variant, bind `[savingMove]` and call `event.rollback()` when the save fails. The node has already moved when `nodeMoved` fires — without the rollback the user is looking at a hierarchy that exists only in this tab.
- Do: Report a failed move through `[moveError]`, not `[error]`: `[error]` replaces the whole tree, which takes the hierarchy away from someone mid-drag.
- Do: On the Pro lazy variant, report a branch failure through `[nodeErrors]` (a `Map` of node id to a message or an `SvDataError`) rather than the id-only `[errorNodeIds]`. Without a message a failed branch is indistinguishable from an empty one, and the user has nothing to retry from.
- Do: Set `emptyBranchText` to whatever "nothing in here" means for your data — "No members", "No files". A lazy branch that loads and returns nothing is otherwise the same picture as one that has not loaded yet.
- Tip: The bindings are shared by all 8 variants, but each variant only reads some of them. In dev mode the component names any binding the active variant ignores — `[columns]` outside `tree-table`, `[checkedIds]` outside `checkbox`, `[savingMove]`/`[moveError]` outside `draggable` — once per binding, in the console.
- Tip: Building `children` from a `parentId` map can produce a cycle, and every walk over a tree recurses. The component cuts cycles before anything walks them and warns which node is its own descendant, so a bad join is a console message rather than a blank tab.
Accessibility
- Keyboard at scale (X-10): the tree is ONE tab stop — expand chevrons and checkboxes leave the tab order; →/← expand, collapse and retry a failed branch, Space ticks a row in the checkbox variant, and the tree-table variant roves its row headers. ↑/↓/Home/End, PageUp/PageDown (ten rows, clamped) and typeahead over labels. Documented ceiling: 2,000 visible rows, past which a dev-mode warning suggests lazy children.
- Focus after a mutation (X-12): when the host removes the focused node through `nodes`, focus moves to the row now at its position, then the one before it, then the tree — never `<body>`.
- WAI-ARIA `tree`/`treeitem` roles with `aria-level`, `aria-expanded` and `aria-selected` state.
- `aria-selected` is published only when `selectable` is true. A display-only tree that reports `aria-selected="false"` on every row announces a selection model that does not exist, and a listener can only discover that by pressing Enter and getting nothing.
- Reordering has full keyboard parity on the draggable variant, because `dragstart` fires for a pointer and for nothing else: a tree whose whole purpose is rearranging a hierarchy could be read by a keyboard user and not changed by one. Ctrl/Cmd + ↑/↓/→/← express exactly what a drop can, and the row publishes them in `aria-keyshortcuts` so the gesture is discoverable rather than folklore.
- A move is announced in an assertive live region — "main.ts moved to position 2 of 3 under src." A reorder is the one gesture whose entire result is off-screen: the row keeps its own name and only its POSITION changes, so without this a keypress had no reported outcome. The position is in the sentence, which is also what makes two consecutive moves differ, and a rejected save announces the put-back for the same reason.
- Focus follows the moved node rather than the position it left, and the re-focus lands after the render: moving a row detaches and re-inserts its element, so focusing it any earlier dropped the user out of the tree with no way to press the shortcut again.
- A lazy branch’s state is on the expand control itself, not only in the note beside it — "Loading Team", "Retry loading Other — Request timed out", "Collapse Team, empty". The control is what the arrow keys land on; the notes are `aria-hidden` so the row is not read twice.
- The ARIA model per variant, stated rather than left to be discovered: `default`, `file-explorer`, `checkbox`, `org-chart`, `searchable`, `draggable` and `lazy` are all `role="tree"` with `treeitem` rows; `tree-table` is a real `<table>` with an `sr-only` `<caption>` and `scope="row"`/`scope="col"` headers.
- The `tree-table` variant is deliberately NOT `role="treegrid"`. A treegrid promises cell-level arrow navigation, and this one navigates by row — claiming the role without the keyboard model behind it strands a user pressing → in a cell that will never move. A plain table with correct headers is the honest description.
- Already a semantic structure, so it publishes no off-screen summary table — a second copy would read every node twice. The `tree-table` variant was the exception: before this it had no caption, no `scope`, and its nesting was carried by `padding-left` alone. Its row header now carries the branch, and repeats it as you move across the data columns, because otherwise "24 MB" arrives attached to nothing.
- Name the tree with `ariaLabel`. An unnamed `role="tree"` announces as "tree" and nothing more, which is unhelpful on any page with two of them.
- Full keyboard navigation: ↑/↓ move, →/← expand/collapse or move to child/parent, Home/End jump, Enter/Space select — via a roving tabindex so the tree is a single tab stop.
- A disabled row is disabled to the cascade too. Its own box was correctly refused and its parent’s box ticked it anyway, so a row the user could not select still arrived in `checkedChange` — and the host granted, exported or deleted on that basis. One locked child no longer stops its branch from completing either: the roll-up reads the enabled children, so a folder with a read-only file in it can still be ticked.
- Checkboxes are individually labelled; search highlighting keeps the underlying text intact.
Feedback on Tree View / Table
Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.