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
BbTablewith its#expandslot — the rows are flat with one level of detail, which is not a treeBbCollapsible— it is one section that opens and closesBbDropdown— 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>[].
<template>
<BbTree
class="w-full max-w-xs rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-1.5"
:expandable="(node) => !!node.children?.length"
item-value="path"
:items="items"
style="--indent: 0.875rem; --gap: 0.125rem"
>
<template #default="{ meta, expanded, expandable, expandProps }">
<!-- A branch is a disclosure control: it opens something on this page. -->
<button
v-if="expandable"
v-bind="expandProps"
class="flex w-full items-center gap-1.5 rounded-[var(--bb-radius-sm)] px-1.5 py-0.5 text-left text-sm hover:bg-[color:var(--bb-surface-hover)]"
type="button"
>
<BbIcon
class="shrink-0 text-[color:var(--bb-text-muted)]"
:icon="expanded ? 'lucide:chevron-down' : 'lucide:chevron-right'"
size="sm"
/>
<span class="truncate font-medium">{{ meta.name }}</span>
</button>
<!--
A leaf is a destination: the navigation rides on the item itself, so
the reader gets a real link — middle-click, open in a new tab, copy
address. `exact-active-class` names the hook that lights the current
one; the CSS below decides what that looks like. Nothing lights up
in this preview on purpose: a fragment target is the page you are
already on, so the matcher never treats it as a destination.
-->
<BbBaseButton
v-else
block
class="file-link flex items-center gap-1.5 rounded-[var(--bb-radius-sm)] px-1.5 py-0.5 pl-[1.55rem] text-left text-sm hover:bg-[color:var(--bb-surface-hover)]"
exact-active-class="file-link--current"
:href="`#${meta.path}`"
>
<BbIcon
class="shrink-0 text-[color:var(--bb-text-faint)]"
icon="lucide:file"
size="sm"
/>
<span class="truncate">{{ meta.name }}</span>
</BbBaseButton>
</template>
</BbTree>
</template>
<script setup lang="ts">
import { BbBaseButton, BbIcon, BbTree } from 'bitboss-ui';
import { fileTreeItems } from '~/demo-data';
const items = fileTreeItems[0]!.children ?? [];
</script>
<style scoped>
.file-link:focus-visible {
box-shadow: 0 0 0 var(--bb-ring-size) var(--bb-ring);
outline: none;
}
.file-link--current {
background: color-mix(in oklab, var(--bb-primary) 8%, var(--bb-panel));
font-weight: 500;
}
</style>
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:
<template>
<BbTree
class="w-full max-w-xs rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-1.5"
:expandable="(node) => !!node.children?.length"
item-value="path"
:items="items"
style="--indent: 0.875rem; --gap: 0.125rem"
>
<!-- One default slot renders every node: a branch is a disclosure button,
a leaf is plain markup. `meta` is your FileNode, fully typed. -->
<template #default="{ meta, expanded, expandable, expandProps }">
<button
v-if="expandable"
v-bind="expandProps"
class="flex w-full items-center gap-1.5 rounded-[var(--bb-radius-sm)] px-1.5 py-0.5 text-left text-sm hover:bg-[color:var(--bb-surface-hover)]"
type="button"
>
<BbIcon
class="shrink-0 text-[color:var(--bb-text-muted)]"
:icon="expanded ? 'lucide:chevron-down' : 'lucide:chevron-right'"
size="sm"
/>
<span class="truncate font-medium">{{ meta.name }}</span>
</button>
<span
v-else
class="flex items-center gap-1.5 px-1.5 py-0.5 pl-[1.55rem] text-sm"
>
<BbIcon
class="shrink-0 text-[color:var(--bb-text-faint)]"
icon="lucide:file"
size="sm"
/>
<span class="truncate">{{ meta.name }}</span>
</span>
</template>
</BbTree>
</template>
<script setup lang="ts">
import { BbIcon, BbTree } from 'bitboss-ui';
import { fileTreeItems } from '~/demo-data';
// The `src` folder of the shared file-tree fixture, already wrapped in the
// { meta, children } envelope BbTree expects.
const items = fileTreeItems[0]!.children ?? [];
</script>
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).
<template>
<BbTree
class="w-full max-w-sm rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-1.5"
:expandable="(node) => !!node.children?.length"
item-value="id"
:items="items"
style="--indent: 0.875rem; --gap: 0.125rem"
>
<template #default="{ meta, item, expanded, expandable, expandProps }">
<!-- A node the predicate accepted: a real disclosure button. -->
<button
v-if="expandable"
v-bind="expandProps"
class="flex w-full items-center gap-1.5 rounded-[var(--bb-radius-sm)] px-1.5 py-0.5 text-left text-sm font-medium hover:bg-[color:var(--bb-surface-hover)]"
type="button"
>
<BbIcon
class="shrink-0 text-[color:var(--bb-text-muted)]"
:icon="expanded ? 'lucide:chevron-down' : 'lucide:chevron-right'"
size="sm"
/>
<span class="truncate">{{ meta.name }}</span>
</button>
<!-- A branch with `expandable: false` on the node itself: its children
still render, frozen open, and there is no toggle to press. -->
<span
v-else-if="item.children?.length"
class="flex items-center gap-1.5 px-1.5 py-1 text-xs font-semibold tracking-wide text-[color:var(--bb-text-muted)] uppercase"
>
{{ meta.name }}
</span>
<!-- A leaf: a person. -->
<span v-else class="flex items-center gap-2 px-1.5 py-0.5 text-sm">
<span class="truncate">{{ meta.name }}</span>
<span class="shrink-0 text-xs text-[color:var(--bb-text-muted)]">
{{ meta.title }}
</span>
</span>
</template>
</BbTree>
</template>
<script setup lang="ts">
import { BbIcon, BbTree } from 'bitboss-ui';
import type { BbTreeItem } from 'bitboss-ui';
import { orgChartItems } from '~/demo-data';
import type { OrgNode } from '~/demo-data';
// Product & Design is pinned: the per-node `expandable` beats the tree-level
// predicate for that node only, so its teams keep their own toggles.
const items: BbTreeItem<OrgNode>[] = [
{ ...orgChartItems[0]!, expandable: false },
orgChartItems[1]!,
];
</script>
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.
expandedItems: src/components, src/components/orders, src/components/catalogue, src/composables, src/assets
<template>
<div class="flex w-full max-w-sm flex-col gap-2">
<div class="flex gap-1.5">
<BbButton size="sm" variant="outline" @click="expanded = [...branches]">
Expand all
</BbButton>
<BbButton size="sm" variant="ghost" @click="expanded = []">
Collapse all
</BbButton>
</div>
<BbTree
v-model:expanded-items="expanded"
class="rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-1.5"
:expandable="(node) => !!node.children?.length"
item-value="path"
:items="items"
style="--indent: 0.875rem; --gap: 0.125rem"
>
<template #default="{ meta, expanded: open, expandable, expandProps }">
<button
v-if="expandable"
v-bind="expandProps"
class="flex w-full items-center gap-1.5 rounded-[var(--bb-radius-sm)] px-1.5 py-0.5 text-left text-sm hover:bg-[color:var(--bb-surface-hover)]"
type="button"
>
<BbIcon
class="shrink-0 text-[color:var(--bb-text-muted)]"
:icon="open ? 'lucide:chevron-down' : 'lucide:chevron-right'"
size="sm"
/>
<span class="truncate font-medium">{{ meta.name }}</span>
</button>
<span v-else class="block truncate px-1.5 py-0.5 pl-[1.55rem] text-sm">
{{ meta.name }}
</span>
</template>
</BbTree>
<p class="text-xs text-[color:var(--bb-text-muted)]">
<code>expandedItems</code>:
{{ expanded.length ? expanded.join(', ') : '(empty)' }}
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbIcon, BbTree } from 'bitboss-ui';
import type { DemoTreeItem, FileNode } from '~/demo-data';
import { fileTreeItems } from '~/demo-data';
const items = fileTreeItems[0]!.children ?? [];
/** Every branch identity — leaves never appear in the model. */
const branchPaths = (nodes: DemoTreeItem<FileNode>[]): string[] =>
nodes.flatMap((node) =>
node.children?.length
? [node.meta.path, ...branchPaths(node.children)]
: []
);
const branches = branchPaths(items);
// Seeded empty: BbTree fills it on first render, because a branch with no
// `expanded: false` joins the model the first time it is seen.
const expanded = ref<string[]>([]);
</script>
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.
<template>
<BbTree
class="w-full max-w-xs rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-1.5"
:expandable="(node) => !!node.children?.length"
item-value="path"
:items="items"
style="--indent: 0.875rem; --gap: 0.125rem"
>
<!-- Depth slot: every node at the root level becomes a section header. -->
<template #0="{ meta, expanded, expandable, expandProps }">
<button
v-if="expandable"
v-bind="expandProps"
class="flex w-full items-center gap-1.5 rounded-[var(--bb-radius-sm)] px-1.5 py-0.5 text-left text-xs font-semibold tracking-wide text-[color:var(--bb-text-muted)] uppercase hover:bg-[color:var(--bb-surface-hover)]"
type="button"
>
<BbIcon
class="shrink-0"
:icon="expanded ? 'lucide:chevron-down' : 'lucide:chevron-right'"
size="sm"
/>
{{ meta.name }}
</button>
<span
v-else
class="block px-1.5 py-0.5 pl-[1.55rem] text-xs font-semibold tracking-wide text-[color:var(--bb-text-muted)] uppercase"
>
{{ meta.name }}
</span>
</template>
<!-- Value slot: one specific node, and it beats the depth slot even
though this node also sits at depth 0. The name is the normalized
identity — `src/composables` resolves the `src_composables` slot. -->
<template #src_composables="{ meta, expanded, expandProps }">
<button
v-bind="expandProps"
class="flex w-full items-center gap-1.5 rounded-[var(--bb-radius-sm)] px-1.5 py-0.5 text-left text-xs font-semibold text-[color:var(--bb-primary)] uppercase"
type="button"
>
<BbIcon
class="shrink-0"
:icon="expanded ? 'lucide:chevron-down' : 'lucide:chevron-right'"
size="sm"
/>
{{ meta.name }}
<BbBadge size="sm" variant="secondary">shared</BbBadge>
</button>
</template>
<!-- Everything else. -->
<template #default="{ meta, expanded, expandable, expandProps }">
<button
v-if="expandable"
v-bind="expandProps"
class="flex w-full items-center gap-1.5 rounded-[var(--bb-radius-sm)] px-1.5 py-0.5 text-left text-sm hover:bg-[color:var(--bb-surface-hover)]"
type="button"
>
<BbIcon
class="shrink-0 text-[color:var(--bb-text-muted)]"
:icon="expanded ? 'lucide:chevron-down' : 'lucide:chevron-right'"
size="sm"
/>
<span class="truncate">{{ meta.name }}</span>
</button>
<span v-else class="block truncate px-1.5 py-0.5 pl-[1.55rem] text-sm">
{{ meta.name }}
</span>
</template>
</BbTree>
</template>
<script setup lang="ts">
import { BbBadge, BbIcon, BbTree } from 'bitboss-ui';
import { fileTreeItems } from '~/demo-data';
const items = fileTreeItems[0]!.children ?? [];
</script>
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.
Leaves that navigate
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
idthatexpandProps'aria-controlspoints 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 — withoutinerta 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
expandPropson 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 redundantaria-labelonly 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.
<template>
<BbTree
v-model:expanded-items="expanded"
class="w-full max-w-xs rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-1.5"
:expandable="(node) => !!node.children?.length"
item-value="path"
:items="tree"
style="--indent: 0.875rem; --gap: 0.125rem"
>
<template #default="{ meta, expanded: open, expandable, expandProps }">
<button
v-if="expandable"
v-bind="expandProps"
class="flex w-full items-center gap-1.5 rounded-[var(--bb-radius-sm)] px-1.5 py-0.5 text-left text-sm hover:bg-[color:var(--bb-surface-hover)]"
type="button"
>
<BbIcon
class="shrink-0 text-[color:var(--bb-text-muted)]"
:icon="open ? 'lucide:folder-open' : 'lucide:folder'"
size="sm"
/>
<span class="truncate font-medium">{{ meta.name }}</span>
</button>
<span
v-else-if="meta.path.endsWith('#loading')"
class="flex items-center gap-1.5 px-1.5 py-0.5 pl-[1.55rem] text-sm text-[color:var(--bb-text-muted)]"
>
<BbSpinner size="xs" /> Loading…
</span>
<span v-else class="block truncate px-1.5 py-0.5 pl-[1.55rem] text-sm">
{{ meta.name }}
</span>
</template>
</BbTree>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { BbIcon, BbSpinner, BbTree } from 'bitboss-ui';
import type { BbTreeItem } from 'bitboss-ui';
import type { FileNode } from '~/demo-data';
import { delay, fileTreeItems } from '~/demo-data';
const source = fileTreeItems[0]!.children ?? [];
const childrenByPath = new Map(
source.map((node) => [node.meta.path, node.children ?? []])
);
/**
* One placeholder child per unloaded folder. Without it the node has no
* children, which makes it a leaf — and a leaf always reports itself expanded,
* so the chevron would open before anything was fetched.
*/
const placeholder = (path: string): BbTreeItem<FileNode> => ({
meta: { path: `${path}#loading`, name: 'Loading', kind: 'file' },
});
// `expanded: false` matters: a branch seen for the first time otherwise joins
// the model on its own, and every folder would fetch at mount.
const tree = ref<BbTreeItem<FileNode>[]>(
source
.filter((node) => node.children?.length)
.map((node) => ({
meta: node.meta,
expanded: false,
children: [placeholder(node.meta.path)],
}))
);
const expanded = ref<string[]>([]);
const loaded = new Set<string>();
const pending = new Set<string>();
watch(expanded, async (paths) => {
for (const path of paths) {
if (loaded.has(path) || pending.has(path)) continue;
const branch = tree.value.find((node) => node.meta.path === path);
if (!branch) continue;
pending.add(path);
try {
branch.children = await delay(childrenByPath.get(path) ?? [], 600);
loaded.add(path);
} finally {
pending.delete(path);
}
}
});
</script>
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.
<template>
<BbTree
class="connector-tree w-full max-w-xs rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-2"
:expandable="(node) => !!node.children?.length"
item-value="path"
:items="items"
>
<template #default="{ meta, expanded, expandable, expandProps }">
<button
v-if="expandable"
v-bind="expandProps"
class="flex items-center gap-1.5 rounded-[var(--bb-radius-sm)] px-1.5 py-0.5 text-sm hover:bg-[color:var(--bb-surface-hover)]"
type="button"
>
<BbIcon
class="shrink-0 text-[color:var(--bb-text-muted)]"
:icon="expanded ? 'lucide:folder-open' : 'lucide:folder'"
size="sm"
/>
<span class="font-medium">{{ meta.name }}</span>
</button>
<span
v-else
class="flex items-center gap-1.5 px-1.5 py-0.5 text-sm text-[color:var(--bb-text-muted)]"
>
<BbIcon
class="shrink-0 text-[color:var(--bb-text-faint)]"
icon="lucide:file"
size="sm"
/>
{{ meta.name }}
</span>
</template>
</BbTree>
</template>
<script setup lang="ts">
import { BbIcon, BbTree } from 'bitboss-ui';
import { fileTreeItems } from '~/demo-data';
const items = fileTreeItems[0]!.children?.slice(0, 2) ?? [];
</script>
<style scoped>
/*
* Connector lines are deliberately not shipped — they are a look, and every
* product wants them slightly different. This is the sanctioned exception to
* "do not style a component's internals": keep the rules under a wrapper class
* and anchor them to the two layout tokens.
*/
.connector-tree {
--indent: 1.25rem;
--gap: 0.25rem;
--line: var(--bb-border);
}
.connector-tree :deep(.bb-tree__children > .bb-tree__node) {
position: relative;
}
/* Trunk: the vertical line that continues while siblings follow. */
.connector-tree
:deep(.bb-tree__children > .bb-tree__node:not(:last-child)::before) {
border-left: 1px solid var(--line);
bottom: 0;
content: '';
left: calc(var(--indent) / -2);
position: absolute;
top: calc(var(--gap) * -1);
}
/* Elbow: drops from the level above and turns into this row. */
.connector-tree :deep(.bb-tree__children > .bb-tree__node > .bb-tree__row) {
position: relative;
}
.connector-tree
:deep(.bb-tree__children > .bb-tree__node > .bb-tree__row::before) {
border-bottom: 1px solid var(--line);
border-bottom-left-radius: var(--bb-radius-sm);
border-left: 1px solid var(--line);
content: '';
height: calc(50% + var(--gap));
left: calc(var(--indent) / -2);
position: absolute;
top: calc(var(--gap) * -1);
width: calc(var(--indent) / 2);
}
</style>
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:
.my-tree .bb-tree__row,
.my-tree .bb-tree__content {
min-width: 0;
}