Skip to content

Nested, expandable data of any depth. You write each node's markup; the component handles structure and expansion.

import { BbTree } from 'bitboss-ui';

On this page

Use it for

Reach for BbTree when the data is genuinely hierarchical and of unbounded depth: a file browser, a docs sidebar, a category picker, an org chart.

Use something else when

  • BbTable with its #expand slot — the rows are flat with one level of detail, which is not a tree
  • BbCollapsible — it is one section that opens and closes
  • BbDropdown — it is a short list of commands

You own each node's markup. BbTree owns recursion, expansion state and disclosure wiring. This split keeps branch controls and destination links semantically correct.

Passthrough

Hover or tap a part to outline it. The toggles turn on loading, errors and warnings where the component has them; only parts get a row.

Navigation nodes

items takes a nested array of BbTreeItem<Meta>: your data in meta, the nesting in children. Declare the array as BbTreeItem<YourType>[] and meta arrives typed in every slot.

A branch is a disclosure button because it reveals content on the current page. A leaf that opens a destination is a real link. Never put both behaviours on a row click.

  • Bind the slot's expandProps to a real <button> on every branch. The slot also receives meta, item, parent, index, depth, value, expanded, expandable and toggleExpanded(); treat them as read-only.
  • Give the leaf href or to through BbBaseButton when the look is yours, or BbButton with variant="ghost" for the library's. Both take active-class / exact-active-class, so the current page lights itself up.
  • item-value is required (item-value="path" above), a key or a function of meta. It gives every node a stable identity, so a refetch can replace every object without closing the folders a reader opened.

Which nodes open, and how they stay open

Nothing is expandable by default. expandable decides which nodes get a live toggle: true for the whole tree, or a function of (item, depth, parent).

Product & Design
Marta VilloresiHead of Product
Nikhil RaghavanProduct Analyst
Mei Lin ChenData Analyst
Sofia MarchettiDesign Lead
Yuki TanakaProduct Designer
Elif YilmazDesign Systems Engineer
Zara AhmadiUX Researcher
Lukas BrandtEngineering Manager
Amara OkonkwoStaff Engineer
Idris BelloFrontend Engineer
Hugo FerreiraBackend Engineer
Mateusz KowalskiInfrastructure Engineer
Andrei PopescuQA Engineer
Kwame MensahMobile Engineer

The everyday form is :expandable="(node) => !!node.children?.length"; the function also locks a level (depth > 2) or gates on item.meta. A node's own expandable: false pins that branch open, with no toggle. If your chevrons do nothing, this prop is missing: without it the tree renders fully expanded and static.

The open branches live in v-model:expandedItems, as their item-value identities:

AppShell.vue
main.ts

expandedItems: src/components

  • To open a node from code, push its value into the model. A branch joins the model on its own the first time it is seen, unless it carries expanded: false. After that the model is the only source of truth, and the item's expanded flag is ignored.
  • Leaves never appear in the model, so expand-all is "every branch identity", as the demo computes. Persist the model to restore open folders on the next visit.
  • Pick an item-value the server keeps stable, an id or a path, never a field that changes with an edit: a node whose identity changes closes, along with its open subtree.

One node, one slot

Beyond default, each node resolves a slot in a fixed order: #<value> for the node whose identity matches, then #<depth> for every node at that level, then default.

OrderTable.vue
OrderStatusBadge.vue
OrderFilters.vue
ProductCard.vue
ProductGrid.vue
AppShell.vue
useOrders.ts
useCatalogue.ts
main.css
logo.svg
main.ts
locales

No files yet

The value slot's name is the identity in kebab-case: src/composables is #src-composables, Order History is #order-history, Q3 Reports is #q-3-reports. For anything exotic write #[slotKey('Qualità')], with import { slotKey } from 'bitboss-ui'. A node whose name matches no slot renders through default.

#<value>:children and #<depth>:children replace the children container of the matching node: the recursion stops there and your markup renders instead. That is how you put an inline empty state under one branch (#src-locales:children above, under the empty locales folder). They render for leaves too, so guard branch-only markup with v-if="item.children?.length".

Give nodes string identities ('folder-1') when you use per-node slots: a node valued 1 and every node at depth 1 both answer to #1.

Coming from v2Per-node slot names

v2 named the slot with the raw value. v3 runs it through slotKey, so rename each per-node slot to the kebab-case identity. A name in the v2 spelling matches no node, and that node renders through default. The children-container slots are #<value>:children, with a colon: bitboss-ui check --fix rewrites the old -children names.

A disclosure list, not a tree

BbTree renders no role="tree", no role="treeitem" and no aria-level / aria-posinset / aria-setsize. An APG tree makes the node itself the single focusable unit, which cannot coexist with slot-driven nodes that hold your links and buttons. Each branch is an ordinary disclosure: a button that shows and hides a region.

What you get:

  • The children container carries the id that expandProps' aria-controls points at. That pairing is the whole disclosure wiring.
  • A collapsed container is inert, so its links and buttons leave the tab order and the accessibility tree.

What you still owe:

  • One real focusable control per row, and expandProps on a <button> for every branch.
  • An accessible name on an icon-only toggle (Collapse src, not a bare chevron). When the toggle contains the node's label, as in the demos, that text is already the name.

Keyboard users Tab through whatever your slots render and toggle with Enter or Space, so your node markup decides how good the keyboard experience is.

Coming from v2Tree roles and tabindex

v2 claimed role="tree" and treeitem without the keyboard contract they promise; v3 drops the roles. Delete any :tabindex="open ? undefined : -1" you wrote for collapsed branches: inert does it now, and the manual tabindex fights it.

Branches that load on first open

A collapsed branch renders its rows the first time it opens, so a mostly closed tree of thousands of nodes is cheap. Open branches are not virtualized: for folders of unknown size, load the children when the branch first opens.

Give each unloaded folder a single placeholder child, so it is a real branch (a childless node is a leaf, and a leaf reports itself expanded). Mark those folders expanded: false, or they open at first sight and every request fires at mount. Then watch the model: when an unloaded folder's identity appears, fetch and swap its children. items is watched deeply, so the tree updates in place.

Indentation, rhythm and connector lines

Two CSS custom properties on .bb-tree shape the layout: --indent shifts each child level and --gap adds space between siblings. Both default to 0, so nothing indents until you set them. Expansion animates on its own.

OrderTable.vue
OrderStatusBadge.vue
OrderFilters.vue
ProductCard.vue
ProductGrid.vue
AppShell.vue
useOrders.ts
useCatalogue.ts

Connector lines are not shipped: they are a look, and every product wants them slightly different. This is the one sanctioned case for styling the internals: .bb-tree__item with its --depth-<n>, --expanded and --leaf modifiers, .bb-tree__row and .bb-tree__children. Write the rules under a wrapper class so they never reach another tree. For a plain per-node style use pt instead: pt:item:expanded="font-medium" weights every open branch (leaves count as expanded).

In a narrow pane a long label truncates with a plain truncate class on your node markup: the row already lets its content shrink.