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
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.
<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="tree-control 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="tree-control 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>
/* One focus ring for branch buttons and leaf links alike. */
.tree-control: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. Never put both behaviours on a row click.
- Bind the slot's
expandPropsto a real<button>on every branch. The slot also receivesmeta,item,parent,index,depth,value,expanded,expandableandtoggleExpanded(); treat them as read-only. - Give the leaf
hrefortothroughBbBaseButtonwhen the look is yours, orBbButtonwithvariant="ghost"for the library's. Both takeactive-class/exact-active-class, so the current page lights itself up. item-valueis required (item-value="path"above), a key or a function ofmeta. 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).
<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 pl-[1.55rem] text-sm">
<span class="shrink-0">{{ meta.name }}</span>
<span class="min-w-0 truncate 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 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:
expandedItems: src/components
<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 { BbTreeItem } from 'bitboss-ui';
import type { DemoTreeItem, FileNode } from '~/demo-data';
import { fileTreeItems } from '~/demo-data';
/** Every branch starts closed, so the seeded model below is the whole truth. */
const closeBranches = (nodes: DemoTreeItem<FileNode>[]): BbTreeItem<FileNode>[] =>
nodes.map((node) =>
node.children?.length
? { meta: node.meta, expanded: false, children: closeBranches(node.children) }
: { meta: node.meta }
);
const items = closeBranches(fileTreeItems[0]!.children ?? []);
/** Every branch identity — leaves never appear in the model. */
const branchPaths = (nodes: BbTreeItem<FileNode>[]): string[] =>
nodes.flatMap((node) =>
node.children?.length
? [node.meta.path, ...branchPaths(node.children)]
: []
);
const branches = branchPaths(items);
// One folder open to start, so both buttons change something on the first
// click. The branches carry `expanded: false`, so none joins the model on its own.
const expanded = ref<string[]>(['src/components']);
</script>
- 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'sexpandedflag 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-valuethe 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.
<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>
<!-- An empty folder has no toggle, but it is still a header. -->
<span
v-else-if="meta.kind === 'folder'"
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>
<!-- A root-level file is still a file: only folders become headers. -->
<span v-else class="block truncate px-1.5 py-0.5 pl-[1.55rem] text-sm">
{{ meta.name }}
</span>
</template>
<!-- Children slot: replaces what renders under one node. `src/locales` has
no children, so this is its inline empty state (these slots render for
leaves too, which is exactly what an empty folder is). -->
<template #src-locales:children>
<p class="m-0 px-1.5 py-0.5 pl-[1.55rem] text-xs text-[color:var(--bb-text-muted)] italic">
No files yet
</p>
</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';
// The source tree plus one empty folder, for the `:children` empty state.
const items = [
...(fileTreeItems[0]!.children ?? []),
{ meta: { path: 'src/locales', name: 'locales', kind: 'folder' as const } },
];
</script>
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
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.
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, 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.
<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>
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.
<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__item) {
position: relative;
}
/* Trunk: the vertical line that continues while siblings follow. */
.connector-tree
:deep(.bb-tree__children > .bb-tree__item: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__item > .bb-tree__row) {
position: relative;
}
.connector-tree
:deep(.bb-tree__children > .bb-tree__item > .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 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.