Use it for
Put BbBreadcrumbs at the top of a detail view, above the title. It answers two
questions at once: where am I, and how do I get back up.
Use something else when
BbTabs, if they are peer views of the same record: Overview, Activity, SettingsBbPagination, if the reader is stepping through a paged result set
Also see: Options and items.
Show one trail per view.
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.
The trail
Each item is { label } plus a navigation target. Add a unique value only to a
crumb that needs a slot of its own.
<template>
<BbBreadcrumbs :items="trail" />
</template>
<script setup lang="ts">
import { BbBreadcrumbs } from 'bitboss-ui';
import type { BbBreadcrumbsItem } from 'bitboss-ui';
import { products } from '~/demo-data';
const product = products[0]!;
// Ancestors carry a target and render as links. The last item carries none:
// that makes it the current page, marked `aria-current="page"` and rendered as
// plain text. No `value` is needed until a crumb wants a slot of its own.
const trail: BbBreadcrumbsItem[] = [
{ label: 'Catalogue', href: '#default' },
{ label: product.category, href: '#default' },
{ label: product.name },
];
</script>
A last item without a target is the current page: it renders as plain text
with aria-current="page", out of the tab order. Give it an href and it is an
ordinary link again, for a trail that stops at the parent page with the page
title below it.
Coming from v2key → value, text → label
A crumb's key is now value and its text is now label; the crumb slots'
scope says label too. npx bitboss-ui check --fix renames both fields on items
it can see. The value must be a string: a numeric key that ran in v2 now fails
type checking, and the fix is String(id).
Every crumb is a link
Give every crumb its own target: href, to, or an onClick.
- AppShell.vue
<template>
<div
class="grid w-full max-w-md gap-2 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-3"
>
<BbBreadcrumbs :items="trail" />
<ul class="divide-y divide-[color:var(--bb-border)] text-sm">
<li
v-for="entry in entries"
:key="entry.path"
class="flex items-center gap-2 py-1.5"
>
<!-- Same size, icon and inset on both rows: only the icon, the colour
and the fact that it opens tell a folder from a file. -->
<BbButton
v-if="entry.children?.length"
prepend:icon="lucide:folder"
variant="ghost"
@click="open(entry)"
>
{{ entry.name }}
</BbButton>
<span
v-else
class="flex min-h-7 items-center gap-1.5 px-2 text-sm text-[color:var(--bb-text-muted)]"
>
<BbIcon icon="lucide:file" size="sm" />
{{ entry.name }}
</span>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbBreadcrumbs, BbButton, BbIcon } from 'bitboss-ui';
import type { BbBreadcrumbsItem } from 'bitboss-ui';
import type { FileNode } from '~/demo-data';
import { fileTree } from '~/demo-data';
const src = fileTree[0]!;
const path = ref<FileNode[]>([src, src.children![0]!]);
const entries = computed(() => path.value.at(-1)?.children ?? []);
const open = (folder: FileNode) => (path.value = [...path.value, folder]);
/*
* The hierarchy lives in this component's state, not in the URL, so ancestors
* get an `onClick` instead of an `href`. Everything else is unchanged: the
* crumbs are still real buttons, and the last one still has no target.
*/
const trail = computed<BbBreadcrumbsItem[]>(() =>
path.value.map((folder, index) => ({
label: folder.name,
...(index < path.value.length - 1
? {
onClick: () => {
path.value = path.value.slice(0, index + 1);
},
}
: {}),
}))
);
</script>
When the hierarchy lives in client state rather than in routes, give the
ancestors an onClick. The crumbs are still real buttons, and crumbs folded into
the overflow menu keep their handler. Never wrap the trail in your own
click-to-route handler.
disabled works at two levels. On one item, that crumb loses its href and
gains aria-disabled. On the component, every crumb and the overflow button
freeze together. Use it for a level the reader can no longer open, rather than
shipping a dead link.
Long trails fold themselves
You do not manage overflow.
Drag the corner to narrow the frame.
<template>
<div class="flex flex-col gap-2">
<!-- A resizable frame stands in for a real layout: drag the bottom-right
corner and watch the earliest crumbs fold into the ellipsis menu. -->
<div
class="max-w-full min-w-[10rem] resize-x overflow-auto rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-3"
style="width: 22rem"
>
<BbBreadcrumbs :items="trail" />
</div>
<p class="text-xs text-[color:var(--bb-text-muted)]">
Drag the corner to narrow the frame.
</p>
</div>
</template>
<script setup lang="ts">
import { BbBreadcrumbs } from 'bitboss-ui';
import type { BbBreadcrumbsItem } from 'bitboss-ui';
// A file path with the workspace and the repository in front of it: six
// levels, more than a narrow column can hold. Pass the whole thing: pre-slicing
// the array to "manage" the width is what the estimator is there to avoid.
const segments = 'src/components/orders/OrderTable.vue'.split('/');
const trail: BbBreadcrumbsItem[] = [
{ label: 'Vantera', href: '#overflow' },
{ label: 'vantera-web', href: '#overflow' },
...segments.map((segment, index) => ({
label: segment,
...(index < segments.length - 1 ? { href: '#overflow' } : {}),
})),
];
</script>
The earliest crumbs fold into the … menu, root first, and keep their targets
there. The current page never folds: at the narrowest its label truncates, with
the full text in a tooltip and for screen readers. If that is too little, give
the trail a full-width row of its own.
Pass the full items array. Pre-slicing removes the keyboard-ready overflow
menu.
The fold point is estimated from label text. Start with
offset-width for icons, slots, and edge regions. Use
estimation-bias, divider-width,
gap-width, or ellipsis-width only when their matching visual changes.
In a flex row, give the trail class="min-w-0 flex-auto" and keep its siblings
shrink-0. The full trail renders on the server, then folds after hydration.
Icons and edge regions
A fixed icon per crumb is an item field, not a slot: prepend:icon and
append:icon.
<template>
<div class="max-w-lg">
<!-- The edge regions are invisible to the width estimator, so their total
is declared once with `offset-width`: the badge before the trail and
the button after it. -->
<BbBreadcrumbs :items="trail" :offset-width="96">
<template #prepend>
<BbBadge class="me-1" size="sm" variant="secondary">Staging</BbBadge>
</template>
<template #append>
<BbButton
:aria-label="copied ? 'Link copied' : 'Copy link to this page'"
class="ms-1"
:icon="copied ? 'lucide:check' : 'lucide:link'"
size="xs"
variant="ghost"
@click="copied = true"
/>
</template>
</BbBreadcrumbs>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbBadge, BbBreadcrumbs, BbButton } from 'bitboss-ui';
import type { BbBreadcrumbsItem } from 'bitboss-ui';
const copied = ref(false);
// A static icon per crumb is an item field, not a slot. It renders inside the
// link, so the whole crumb stays one target.
const trail: BbBreadcrumbsItem[] = [
{ label: 'Settings', href: '#edges', 'prepend:icon': 'lucide:settings' },
{ label: 'Team', href: '#edges', 'prepend:icon': 'lucide:users' },
{ label: 'Members', 'prepend:icon': 'lucide:user-round' },
];
</script>
prepend and append hold content before and after the whole trail,
outside the links: an environment badge, a compact action. Declare their width
with offset-width, or the trail folds too late.
For content that varies per crumb, use item:prepend or item:append. These slots are
inside the crumb link, so do not add interactive descendants. They replace that
side's prepend:icon or append:icon. Their item.value
can be undefined, since values are optional.
One crumb, its own slot
Every item with a value also gets slots named after it: #<value> replaces that
crumb's label, #<value>:prepend and #<value>:append what sits before and after
it.
<template>
<div>
<BbBreadcrumbs :items="trail">
<!--
The slot name is the item's value in kebab-case: `Order History`,
`orderHistory` and `order_history` all resolve `#order-history`. A
name that matches no value renders the default label.
-->
<template #order-history="{ label }">
<span class="inline-flex items-center gap-1.5">
{{ label }}
<BbBadge size="sm" variant="secondary">{{ orders.length }}</BbBadge>
</span>
</template>
<!--
`#<value>:append` fills the space after one crumb's label: the current
order carries its status, and every other crumb stays plain.
-->
<template #order:append>
<BbBadge size="sm" variant="outline">{{ orderStatusLabels[order.status] }}</BbBadge>
</template>
</BbBreadcrumbs>
</div>
</template>
<script setup lang="ts">
import { BbBadge, BbBreadcrumbs } from 'bitboss-ui';
import type { BbBreadcrumbsItem } from 'bitboss-ui';
import { orders, orderStatusLabels } from '~/demo-data';
const order = orders[0]!;
const trail: BbBreadcrumbsItem[] = [
{ label: 'Dashboard', href: '#crumb-slot' },
{ value: 'Order History', label: 'Order history', href: '#crumb-slot' },
{ value: 'order', label: order.reference },
];
</script>
Use these slots when one level needs richer content. A crumb in the overflow
menu is a dropdown row with the same value and label, so it follows the dropdown's rules.
The slot name is the value in kebab-case: Order History, orderHistory and
order_history are all served by #order-history. For a value with accents or
another script, write #[slotKey(value)] with slotKey from bitboss-ui. A crumb
without a value has no slot of its own; the name is never taken from its label.
Coming from v2slot names are kebab-case
Rename each per-crumb slot to the kebab-case value: a name in the old spelling
matches no crumb, which then shows its default label. npx bitboss-ui check
names the new spelling when the items are in the same file.
A value spelled like one of the component's own slots (prepend, append,
divider, ellipsis, item) is an error.
Divider and overflow button
The separator between crumbs is a slot, and only a slot.
<template>
<div class="max-w-72">
<!-- Both replacements are narrower than the defaults (the `/` is 4px, the
`+N` about 24px). Declare their widths and the estimator folds no more
crumbs than it has to. -->
<BbBreadcrumbs :divider-width="4" :ellipsis-width="24" :items="trail">
<template #divider>
<span class="text-[color:var(--bb-text-muted)]">/</span>
</template>
<template #ellipsis="{ overflowCount }">
<span class="text-xs font-medium">+{{ overflowCount }}</span>
</template>
</BbBreadcrumbs>
</div>
</template>
<script setup lang="ts">
import { BbBreadcrumbs } from 'bitboss-ui';
import type { BbBreadcrumbsItem } from 'bitboss-ui';
const trail: BbBreadcrumbsItem[] = [
{ label: 'Library', href: '#styling' },
{ label: 'Photos', href: '#styling' },
{ label: 'Albums', href: '#styling' },
{ label: 'Trips', href: '#styling' },
{ label: 'Italy', href: '#styling' },
{ label: 'Summer 2026' },
];
</script>
Dividers are decorative and stay aria-hidden, so never encode meaning in one.
If yours is wider or narrower than the default, declare its width with
divider-width.
The ellipsis slot receives overflowCount and keeps the accessible button and
menu behaviour. Declare a replacement's width with ellipsis-width, as the demo
does.
Coming from v2divider removed
There is no divider prop in v3, and passing it leaks an attribute onto the
markup. The default separator is a small chevron, not a /. The default of
divider-width moved from 5 to 16, which matters only if you had tuned the
old value.