Skip to content

Nested, expandable data of any depth — you own every node's markup, the component owns the structure and the expansion state.

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

Pass Through

Hover or tap a part to outline it. Toggles flip loading, errors and warnings when the component has them — only parts highlight.

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

Navigation nodes

items takes a nested array of BbTreeItem<Meta>. The envelope is fixed — meta, children, and two optional flags — so the component can reason about the structure; meta is your data, and it arrives typed in every slot when you declare the array as BbTreeItem<YourType>[].

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

Set item-value immediately. It gives every node a stable identity for expansion state and named slots. A refetch can safely replace every object without closing the folders a reader already opened.

The smaller rendering contract looks like this:

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

The slot receives meta, item, parent, index, depth, value, expanded, expandable, toggleExpanded() and expandProps. Bind expandProps to a real button. Treat the payload as read-only and change expansion through the callback or model.

Which nodes toggle

Nothing is expandable by default. expandable is what decides which nodes get a live toggle and an aria-expanded, as a boolean for the whole tree or as 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 form earns its keep when the rule is not just "has children": lock a level with depth > 2, or gate on something in item.meta.

A node's own expandable field overrides the tree-level prop for that node alone. Setting it to false on a branch does not hide its children — they render expanded and frozen, with no chevron, which is how you pin a section open.

If your chevrons do nothing, this prop is missing. A tree without expandable renders fully expanded and completely static.

Identity and expansion state

item-value gives each node a stable identity — a key of meta (item-value="path") or a function (meta) => value. Live expansion is then a v-model:expandedItems holding those identities.

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

expandedItems: src/components, src/components/orders, src/components/catalogue, src/composables, src/assets

Set item-value whenever the items come from a server. Without it the identity is a hash of the whole meta object, so a refetch that rebuilds the objects with an extra field or a different property order silently resets the expansion. It is also what makes the model readable, and what names the per-node slots in the next section.

The seeding rule is worth reading twice: the first time a branch is seen — at first render, or when it is appended later — it joins the model automatically unless it carries expanded: false or is already tracked. After that first sighting the model is the only source of truth, and later flips of the expanded flag are ignored. To open a node from code, push its value into the model; do not mutate the flag.

Leaves never appear in the model, so expand-all is exactly "the identity of every branch", as the demo computes. Write the model to persist open folders between visits or restore them on reload.

One node, one slot

Beyond default, the component resolves a slot per node in a fixed priority 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

Value slots only exist when the identity resolves to a string or a number, and the name is normalised: runs of non-word characters collapse to _ and the result is lowercased, so src/composables resolves #src_composables and Order History resolves #order_history. In v2 the slot took the raw value. Nothing warns about a stale name — the node quietly falls back to default — so this is the first thing to check when a per-node slot stops rendering after an upgrade.

Each slot receives the same payload as default. There are also #<value>-children and #<depth>-children slots, which replace the children container of the matching node: the recursion stops there and your markup renders instead. That is the way to put a custom layout, or an inline empty state, under one branch without touching the rest of the tree.

Two catches. A -children slot also renders for leaves, which have no children of their own, so a #1-children that should only follow branches needs a v-if="item.children?.length" inside it. And numeric identities share names with depth slots: a node whose value is 1 and every node at depth 1 both answer to #1. The node still wins for itself, but the markup no longer says which one you meant, so give nodes string identities ('folder-1') when you use per-node slots.

A leaf that opens something is a link, and the navigation belongs to the leaf's own markup — never to an @click that calls the router. The first demo on this page shows the complete branch-button and leaf-link split.

Give the leaf href or to through BbBaseButton when the look is yours, or BbButton with variant="ghost" when you want the library's. Both carry active-class / exact-active-class, so the current page highlights itself instead of being tracked in a second piece of state.

In a Nuxt or Vue Router application the leaf takes :to; under Inertia an href is the visit. The branch stays a <button> either way: it opens a region on this page, which is not navigation.

A disclosure list, not a tree

BbTree renders no role="tree", no role="treeitem" and no aria-level/aria-posinset/aria-setsize — deliberately, and this changed in v3. An APG tree makes the tree item itself the single focusable unit under a roving tabindex, which cannot coexist with slot-driven nodes that hold your links and your buttons. Announcing a keyboard contract the component cannot honour is worse than not claiming the role, so the claim is gone. Each branch is an ordinary disclosure: a button that shows and hides a region.

What you get automatically:

  • 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. Collapsing is CSS-only, which hides pixels but not focusability — without inert a collapsed branch would stay Tab-reachable while invisible. If you are migrating, delete any :tabindex="open ? undefined : -1" you wrote for v2: it now fights the component.

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 above, that text is already the name and a redundant aria-label only overrides it.

Keyboard users Tab through whatever your slots render and toggle with Enter or Space. There is nothing tree-specific to learn, which is the point — but it does mean your node markup decides how good the keyboard experience is.

Branches that load on first open

The whole tree stays mounted: collapsed branches are hidden with CSS, not removed, and there is no virtualization. For hundreds of nodes that is fine. For folders of unknown size, load the children when the branch first opens.

Loading…
Loading…
Loading…

Two details make the pattern work. 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, so a chevron driven by expanded would render open before anything was fetched. And mark those folders expanded: false, or the seeding rule opens them at first sight and every request fires at mount.

Then watch the model: when an unloaded folder's identity appears in it, fetch and swap its children. items is watched deeply, so appending children updates the tree in place, and the new branches follow the same seeding rules.

Indentation, rhythm and connector lines

Two CSS custom properties on .bb-tree shape the layout: --indent shifts each child level and --gap adds vertical space between siblings. Both default to 0, so nothing indents until you set them — inline, in a class, or per instance. Expansion animates on its own, as a grid-rows transition rather than a remount.

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

Connector lines are deliberately not shipped: they are a look, not a behaviour, and every product wants them slightly different. This is the one sanctioned case for styling the component's internals — .bb-tree__node, .bb-tree__row, .bb-tree__children, plus the --depth-<n>, --expanded and --leaf modifier classes — and the rules stay under a wrapper class so they never leak to another tree.

The same exception covers one other case. In a narrow pane long labels overflow instead of truncating, because .bb-tree__content is a flex child at its default min-width: auto. Force it to zero under your own wrapper class, then truncate in your node markup:

css
.my-tree .bb-tree__row,
.my-tree .bb-tree__content {
    min-width: 0;
}