Tree View / Table

Free

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

</>sv-tree-view treehierarchytable

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 demo

More examples

Selectable with guide lines

example.htmlAngular
<sv-tree-view [nodes]="nodes" [selectable]="true" [showLines]="true" (nodeSelect)="onSelect($event)" />

Saving a move, and putting it back (Pro)

example.htmlAngular
<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)

example.htmlAngular
<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 Change events to store what the user does.

Inputs

NameTypeDefaultDescription
nodesTreeNode[][]Tree node data (nested children, optional icons).
selectablebooleantrueEnable single-row selection highlight.
showLinesbooleantrueShow vertical connector guide lines.
ariaLabelstring''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.
loadingbooleanfalseTrue while the tree is being fetched. Replaces it with a placeholder.
errorSvDataError | string | nullnullLoad failure. Use `toSvDataError()` from `@styloviz/core` to normalize an `HttpErrorResponse`.
emptyTextstring'No items to display'Copy for the empty state.
expandedIdsSet<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.
selectedIdstring | null (two-way)nullThe selected node id. Two-way bindable.

Outputs

EventPayload typeDescription
nodeClickTreeNodeClickEventFires when an enabled node is clicked or activated with Enter/Space while `selectable`; payload is the node and its depth (0 for roots).
nodeExpandTreeNodeFires with the node when an enabled parent is expanded, by its chevron or ArrowRight.
nodeCollapseTreeNodeFires with the node when an expanded parent is collapsed, by its chevron or ArrowLeft.
nodeSelectTreeNodeFires with the node when it becomes selected by click or Enter/Space; never fires while `selectable` is false.
retryvoidThe host should re-run the failed load.
expandedIdsChangeSet<string>Emits the new `expandedIds` whenever it changes — the output half of `[(expandedIds)]`.
selectedIdChangestring | nullEmits 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 Change events 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 moveError to show it in place — without taking away what the user was working on.

Inputs

NameTypeDefaultDescription
nodesTreeNode[][]Tree node data.
variantTreeVariant'default'Rendering variant.
columnsTreeColumn[][]Column definitions — required for `tree-table` variant.
selectablebooleantrueEnable single-row selection highlight.
showLinesbooleantrueShow vertical connector guide lines.
sizeTreeSize'md'Component size preset.
emptyTextstring'No items to display'Label shown when there are no nodes or no search results.
customClassstring''Additional CSS classes on the host container.
ariaLabelstring''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.
loadingbooleanfalseTrue 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.
errorSvDataError | string | nullnullLoad failure for the root nodes. Use `toSvDataError()` from `@styloviz/core` to normalize an `HttpErrorResponse`.
loadingNodeIdsreadonly 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.
errorNodeIdsreadonly 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.
nodeErrorsReadonlyMap<string, SvDataError | string> | nullnullWhy 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`.
emptyBranchTextstring'Empty'What an expanded branch says when its load came back with no children.
serverSidebooleanfalseSearch 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.
debounceMsnumber300Delay before `searchChange` fires, in milliseconds. `0` emits immediately.
savingMovebooleanfalseTrue 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.
moveErrorSvDataError | string | nullnullA `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.
demoModebooleanfalseFabricate 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.
expandedIdsSet<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.
selectedIdstring | null (two-way)nullThe selected node id. Two-way bindable.
checkedIdsSet<string> (two-way)new Set()Checked node ids. Two-way bindable, so a saved selection can be restored.
searchQuerystring (two-way)''Search query. Two-way bindable, and bound via ngModel in the searchable variant.

Outputs

EventPayload typeDescription
nodeClickTreeNodeClickEventEmitted when any node row is clicked.
nodeExpandTreeNodeEmitted when a node is expanded.
nodeCollapseTreeNodeEmitted when a node is collapsed.
nodeSelectTreeNodeEmitted when a node row is selected (selectable=true).
checkedChangestring[]Emitted when checkbox state changes; payload is array of all checked IDs.
nodeMovedTreeNodeMoveEventA 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.
lazyLoadTreeNodeEmitted when a lazy node is expanded for the first time.
searchChangestringThe search term changed — fetch matching nodes. Debounced by `debounceMs` and never emitted twice for the same term. Pair with `serverSide`.
retryvoidThe host should re-run the failed root load.
expandedIdsChangeSet<string>Emits the new `expandedIds` whenever it changes — the output half of `[(expandedIds)]`.
selectedIdChangestring | nullEmits the new `selectedId` whenever it changes — the output half of `[(selectedId)]`.
checkedIdsChangeSet<string>Emits the new `checkedIds` whenever it changes — the output half of `[(checkedIds)]`.
searchQueryChangestringEmits 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.

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

host.component.tsTypeScript
// 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.

Loads giscus.app and GitHub — nothing is fetched until you click.