Breadcrumb
Breadcrumb tells people where the current page lives in an application hierarchy. Pass one ordered list. Klean makes every ancestor with an href a real framework-native Inertia Link and infers the final item as the current page.
There is no item component, link adapter, separator prop, collapse setting, visual variant, or route configuration. The Boring Stack already has a Link, and the ordered data already says what the trail means.
Breadcrumb.vue
<script setup>
import { Link } from "@inertiajs/vue3";
import { computed, useAttrs } from "vue";
import { twMerge } from "tailwind-merge";
defineOptions({ inheritAttrs: false });
const props = defineProps({
/** Ordered ancestors followed by the current page. */
items: { type: Array, required: true },
});
const attrs = useAttrs();
const crumbs = computed(() => props.items ?? []);
const lastIndex = computed(() => crumbs.value.length - 1);
const collapses = computed(() => crumbs.value.length > 3);
const label = computed(() => attrs["aria-label"] || "Breadcrumb");
const rootAttrs = computed(() => {
const {
class: _class,
"aria-label": _ariaLabel,
"data-slot": _dataSlot,
...rest
} = attrs;
return rest;
});
const LINK_CLASSES =
"inline-flex min-h-11 min-w-0 max-w-48 cursor-pointer items-center rounded-sm px-1 text-gray-500 no-underline transition-colors hover:text-gray-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 dark:text-gray-400 dark:hover:text-white dark:focus-visible:outline-white";
const LABEL_CLASSES =
"inline-flex min-h-11 min-w-0 max-w-48 items-center px-1 text-gray-500 dark:text-gray-400";
const CURRENT_CLASSES =
"inline-flex min-h-11 min-w-0 max-w-64 items-center px-1 font-medium text-gray-950 dark:text-white";
function itemClass(index) {
return twMerge(
"flex min-w-0 shrink-0 items-center gap-1.5",
collapses.value && index > 0 && index < lastIndex.value - 1
? "hidden @lg:flex"
: undefined,
index === lastIndex.value ? "shrink" : undefined,
);
}
</script>
<template>
<nav
v-if="crumbs.length"
v-bind="rootAttrs"
data-slot="breadcrumb"
:aria-label="label"
:class="twMerge('@container min-w-0', attrs.class)"
>
<ol data-slot="list" class="flex min-w-0 items-center gap-1.5 overflow-hidden text-sm">
<template v-for="(item, index) in crumbs" :key="`${index}-${item.label}`">
<li
v-if="collapses && index === lastIndex - 1"
data-slot="ellipsis"
class="flex shrink-0 items-center gap-1.5 @lg:hidden"
>
<svg data-slot="separator" aria-hidden="true" class="size-3.5 shrink-0 text-gray-400 dark:text-gray-600" viewBox="0 0 16 16" fill="none">
<path d="m6 3.5 4.5 4.5L6 12.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span class="inline-flex min-h-11 items-center px-1 text-gray-400 dark:text-gray-500">
<span aria-hidden="true">…</span>
<span class="sr-only">Collapsed breadcrumb items</span>
</span>
</li>
<li
data-slot="item"
:data-index="index"
:data-state="index === lastIndex ? 'current' : undefined"
:class="itemClass(index)"
>
<svg
v-if="index > 0"
data-slot="separator"
aria-hidden="true"
class="size-3.5 shrink-0 text-gray-400 dark:text-gray-600"
viewBox="0 0 16 16"
fill="none"
>
<path d="m6 3.5 4.5 4.5L6 12.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span
v-if="index === lastIndex"
data-slot="current"
aria-current="page"
:title="item.title"
:class="CURRENT_CLASSES"
>
<span class="truncate">{{ item.label }}</span>
</span>
<Link
v-else-if="item.href"
:href="item.href"
data-slot="link"
:title="item.title"
:class="LINK_CLASSES"
>
<span class="truncate">{{ item.label }}</span>
</Link>
<span
v-else
data-slot="label"
:title="item.title"
:class="LABEL_CLASSES"
>
<span class="truncate">{{ item.label }}</span>
</span>
</li>
</template>
</ol>
</nav>
</template>Installation
One command detects Vue, React, or Svelte, installs the matching official Inertia adapter when needed, and writes one editable source file into the conventional component directory:
Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.
npx klean-ui add breadcrumbpnpm dlx klean-ui add breadcrumbyarn dlx klean-ui add breadcrumbbunx klean-ui add breadcrumb- No initializer or configuration file
- No framework, alias, or theme questions
- No Klean runtime dependency
There is no initializer, provider, klean-ui.json, generated class helper, or runtime Klean package to configure.
Usage
Write the hierarchy your page already knows. Ancestors receive destinations; the final item does not need one.
Vue
<script setup>
import Breadcrumb from '@/components/ui/breadcrumb/Breadcrumb.vue'
const items = [
{ label: 'Projects', href: '/' },
{ label: 'Slipway', href: '/projects/slipway' },
{ label: 'Settings' }
]
</script>
<template>
<Breadcrumb :items="items" />
</template>
React
import Breadcrumb from '@/components/ui/breadcrumb/Breadcrumb.jsx'
const items = [
{ label: 'Projects', href: '/' },
{ label: 'Slipway', href: '/projects/slipway' },
{ label: 'Settings' }
]
export default function ProjectBreadcrumb() {
return <Breadcrumb items={items} />
}
Svelte
<script>
import Breadcrumb from '$lib/components/ui/breadcrumb/Breadcrumb.svelte'
const items = [
{ label: 'Projects', href: '/' },
{ label: 'Slipway', href: '/projects/slipway' },
{ label: 'Settings' }
]
</script>
<Breadcrumb {items} />
The object shape is identical in every framework. Only the framework's ordinary component syntax changes.
API
| Input | Default | Purpose |
|---|---|---|
items | required | Ordered { label, href?, title? } records ending with the current page. |
aria-label | Breadcrumb | Accessible name for the navigation landmark. |
class / className | — | Ordinary Tailwind classes merged on the navigation root. |
| native attributes | — | IDs, test hooks, and other navigation attributes forwarded to the root. |
The final record is always rendered as current-page text with aria-current="page". If it accidentally contains an href, Klean ignores that destination instead of creating a link to the page the user is already viewing. Earlier records without an href are truthful plain text.
title is optional native text for a truncated or technical label, such as a full filename or record identifier. Klean does not invent a second label API.
Durable by default
Breadcrumb is navigation, so its ancestors remain destinations rather than click handlers:
- Vue, React, and Svelte use their official Inertia Link automatically;
- modified clicks, opening in a new tab, prefetch policy, and browser history remain native framework behavior;
- every destination is visible in the rendered markup and can be copied or inspected;
- the current page is not a redundant link;
- the navigation landmark contains one ordered list at every width;
- decorative chevrons stay out of the accessibility tree;
- no trail state is written to local storage or recreated from browser history.
The application still owns route construction, authorization, localization, and the labels that truthfully describe its hierarchy.
Responsive paths and long names
Deep trails condense automatically when the component's own container is narrow. Klean keeps the root, a non-interactive ellipsis, the nearest parent, and the current page. When the container has enough room, the omitted ancestors return. This works inside compact headers and panels without relying on the viewport or rendering a second mobile landmark.
Labels truncate rather than forcing the page wider. Supply a native title when preserving the complete text on hover is useful:
<Breadcrumb
:items="[
{ label: 'Projects', href: '/' },
{
label: project.name,
href: `/projects/${project.slug}`,
title: project.name
},
{ label: file.name, title: file.name }
]"
/>Do not put omitted ancestors inside an interactive menu merely because the path is long. An ellipsis here describes responsive omission; it does not pretend to be an action. If a product genuinely needs ancestor discovery, compose a separately labelled Menu.
Styling with Tailwind
The installed file is application source, so edit its baseline classes when the whole product needs a different treatment. For a contextual finish, target the stable data slots with ordinary Tailwind:
<Breadcrumb
:items="items"
class="**:data-[slot=link]:font-medium **:data-[slot=link]:text-black/55 **:data-[slot=link]:hover:text-black **:data-[slot=current]:font-bold **:data-[slot=current]:text-black **:data-[slot=separator]:text-black/35"
/>The available hooks are breadcrumb, list, item, link, label, separator, ellipsis, and current; the final item also has data-state="current". These are styling seams, not a visual-variant API.
Slipway migration recipe
Slipway pages already know their route hierarchy, so migration is data replacement rather than a routing abstraction:
<Breadcrumb
:items="[
{ label: 'projects', href: '/' },
{ label: project.name, href: `/projects/${project.slug}` },
{
label: environment.name,
href: `/projects/${project.slug}/environments/${environment.slug}`
},
{ label: app.name, href: appUrl },
{ label: 'deployments' }
]"
/>Use the same shape for Bridge records, Helm, Dock, and content-editor files. Keep any real Back button separate: Breadcrumb describes hierarchy, while Back follows visit history.
When to use
Use Breadcrumb on deeply nested application pages where parent destinations help people understand or change location: project environments, app settings, deployments, Bridge records, content files, and administrative resources.
When not to use
- Use an ordinary Back link or button when the intent is to return to the previous place rather than visit a parent.
- Use Tabs when the choices are peer sections or peer routes, not ancestors.
- Use Sidebar or primary navigation for the application's global destinations.
- Use a numbered step list for progress through a workflow.
- Omit Breadcrumb when the page has no meaningful hierarchy or the same information is already clear and nearby.
Complete framework source
Copy, inspect, and change the complete one-file source for your framework.
Vue source
<script setup>
import { Link } from "@inertiajs/vue3";
import { computed, useAttrs } from "vue";
import { twMerge } from "tailwind-merge";
defineOptions({ inheritAttrs: false });
const props = defineProps({
/** Ordered ancestors followed by the current page. */
items: { type: Array, required: true },
});
const attrs = useAttrs();
const crumbs = computed(() => props.items ?? []);
const lastIndex = computed(() => crumbs.value.length - 1);
const collapses = computed(() => crumbs.value.length > 3);
const label = computed(() => attrs["aria-label"] || "Breadcrumb");
const rootAttrs = computed(() => {
const {
class: _class,
"aria-label": _ariaLabel,
"data-slot": _dataSlot,
...rest
} = attrs;
return rest;
});
const LINK_CLASSES =
"inline-flex min-h-11 min-w-0 max-w-48 cursor-pointer items-center rounded-sm px-1 text-gray-500 no-underline transition-colors hover:text-gray-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 dark:text-gray-400 dark:hover:text-white dark:focus-visible:outline-white";
const LABEL_CLASSES =
"inline-flex min-h-11 min-w-0 max-w-48 items-center px-1 text-gray-500 dark:text-gray-400";
const CURRENT_CLASSES =
"inline-flex min-h-11 min-w-0 max-w-64 items-center px-1 font-medium text-gray-950 dark:text-white";
function itemClass(index) {
return twMerge(
"flex min-w-0 shrink-0 items-center gap-1.5",
collapses.value && index > 0 && index < lastIndex.value - 1
? "hidden @lg:flex"
: undefined,
index === lastIndex.value ? "shrink" : undefined,
);
}
</script>
<template>
<nav
v-if="crumbs.length"
v-bind="rootAttrs"
data-slot="breadcrumb"
:aria-label="label"
:class="twMerge('@container min-w-0', attrs.class)"
>
<ol data-slot="list" class="flex min-w-0 items-center gap-1.5 overflow-hidden text-sm">
<template v-for="(item, index) in crumbs" :key="`${index}-${item.label}`">
<li
v-if="collapses && index === lastIndex - 1"
data-slot="ellipsis"
class="flex shrink-0 items-center gap-1.5 @lg:hidden"
>
<svg data-slot="separator" aria-hidden="true" class="size-3.5 shrink-0 text-gray-400 dark:text-gray-600" viewBox="0 0 16 16" fill="none">
<path d="m6 3.5 4.5 4.5L6 12.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span class="inline-flex min-h-11 items-center px-1 text-gray-400 dark:text-gray-500">
<span aria-hidden="true">…</span>
<span class="sr-only">Collapsed breadcrumb items</span>
</span>
</li>
<li
data-slot="item"
:data-index="index"
:data-state="index === lastIndex ? 'current' : undefined"
:class="itemClass(index)"
>
<svg
v-if="index > 0"
data-slot="separator"
aria-hidden="true"
class="size-3.5 shrink-0 text-gray-400 dark:text-gray-600"
viewBox="0 0 16 16"
fill="none"
>
<path d="m6 3.5 4.5 4.5L6 12.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span
v-if="index === lastIndex"
data-slot="current"
aria-current="page"
:title="item.title"
:class="CURRENT_CLASSES"
>
<span class="truncate">{{ item.label }}</span>
</span>
<Link
v-else-if="item.href"
:href="item.href"
data-slot="link"
:title="item.title"
:class="LINK_CLASSES"
>
<span class="truncate">{{ item.label }}</span>
</Link>
<span
v-else
data-slot="label"
:title="item.title"
:class="LABEL_CLASSES"
>
<span class="truncate">{{ item.label }}</span>
</span>
</li>
</template>
</ol>
</nav>
</template>
React source
import { Link } from "@inertiajs/react";
import { Fragment, forwardRef } from "react";
import { twMerge } from "tailwind-merge";
const LINK_CLASSES =
"inline-flex min-h-11 min-w-0 max-w-48 cursor-pointer items-center rounded-sm px-1 text-gray-500 no-underline transition-colors hover:text-gray-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 dark:text-gray-400 dark:hover:text-white dark:focus-visible:outline-white";
const LABEL_CLASSES =
"inline-flex min-h-11 min-w-0 max-w-48 items-center px-1 text-gray-500 dark:text-gray-400";
const CURRENT_CLASSES =
"inline-flex min-h-11 min-w-0 max-w-64 items-center px-1 font-medium text-gray-950 dark:text-white";
const SEPARATOR_CLASSES =
"size-3.5 shrink-0 text-gray-400 dark:text-gray-600";
function Separator() {
return (
<svg
data-slot="separator"
aria-hidden="true"
className={SEPARATOR_CLASSES}
viewBox="0 0 16 16"
fill="none"
>
<path
d="m6 3.5 4.5 4.5L6 12.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
const Breadcrumb = forwardRef(function Breadcrumb(
{
items = [],
className,
"aria-label": ariaLabel = "Breadcrumb",
"data-slot": _dataSlot,
...navProps
},
forwardedRef,
) {
if (!items.length) return null;
const lastIndex = items.length - 1;
const collapses = items.length > 3;
function itemClass(index) {
return twMerge(
"flex min-w-0 shrink-0 items-center gap-1.5",
collapses && index > 0 && index < lastIndex - 1
? "hidden @lg:flex"
: undefined,
index === lastIndex ? "shrink" : undefined,
);
}
return (
<nav
{...navProps}
ref={forwardedRef}
data-slot="breadcrumb"
aria-label={ariaLabel}
className={twMerge("@container min-w-0", className)}
>
<ol
data-slot="list"
className="flex min-w-0 items-center gap-1.5 overflow-hidden text-sm"
>
{items.map((item, index) => (
<Fragment key={`${index}-${item.label}`}>
{collapses && index === lastIndex - 1 ? (
<li
data-slot="ellipsis"
className="flex shrink-0 items-center gap-1.5 @lg:hidden"
>
<Separator />
<span className="inline-flex min-h-11 items-center px-1 text-gray-400 dark:text-gray-500">
<span aria-hidden="true">…</span>
<span className="sr-only">Collapsed breadcrumb items</span>
</span>
</li>
) : null}
<li
data-slot="item"
data-index={index}
data-state={index === lastIndex ? "current" : undefined}
className={itemClass(index)}
>
{index > 0 ? <Separator /> : null}
{index === lastIndex ? (
<span
data-slot="current"
aria-current="page"
title={item.title}
className={CURRENT_CLASSES}
>
<span className="truncate">{item.label}</span>
</span>
) : item.href ? (
<Link
href={item.href}
data-slot="link"
title={item.title}
className={LINK_CLASSES}
>
<span className="truncate">{item.label}</span>
</Link>
) : (
<span
data-slot="label"
title={item.title}
className={LABEL_CLASSES}
>
<span className="truncate">{item.label}</span>
</span>
)}
</li>
</Fragment>
))}
</ol>
</nav>
);
});
export default Breadcrumb;
Svelte source
<script>
import { Link } from "@inertiajs/svelte";
import { twMerge } from "tailwind-merge";
let {
items = [],
class: className,
"aria-label": ariaLabel = "Breadcrumb",
"data-slot": _dataSlot,
...navProps
} = $props();
const LINK_CLASSES =
"inline-flex min-h-11 min-w-0 max-w-48 cursor-pointer items-center rounded-sm px-1 text-gray-500 no-underline transition-colors hover:text-gray-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 dark:text-gray-400 dark:hover:text-white dark:focus-visible:outline-white";
const LABEL_CLASSES =
"inline-flex min-h-11 min-w-0 max-w-48 items-center px-1 text-gray-500 dark:text-gray-400";
const CURRENT_CLASSES =
"inline-flex min-h-11 min-w-0 max-w-64 items-center px-1 font-medium text-gray-950 dark:text-white";
let lastIndex = $derived(items.length - 1);
let collapses = $derived(items.length > 3);
function itemClass(index) {
return twMerge(
"flex min-w-0 shrink-0 items-center gap-1.5",
collapses && index > 0 && index < lastIndex - 1
? "hidden @lg:flex"
: undefined,
index === lastIndex ? "shrink" : undefined,
);
}
</script>
{#snippet Separator()}
<svg
data-slot="separator"
aria-hidden="true"
class="size-3.5 shrink-0 text-gray-400 dark:text-gray-600"
viewBox="0 0 16 16"
fill="none"
>
<path
d="m6 3.5 4.5 4.5L6 12.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{/snippet}
{#if items.length}
<nav
{...navProps}
data-slot="breadcrumb"
aria-label={ariaLabel}
class={twMerge("@container min-w-0", className)}
>
<ol data-slot="list" class="flex min-w-0 items-center gap-1.5 overflow-hidden text-sm">
{#each items as item, index (`${index}-${item.label}`)}
{#if collapses && index === lastIndex - 1}
<li
data-slot="ellipsis"
class="flex shrink-0 items-center gap-1.5 @lg:hidden"
>
{@render Separator()}
<span class="inline-flex min-h-11 items-center px-1 text-gray-400 dark:text-gray-500">
<span aria-hidden="true">…</span>
<span class="sr-only">Collapsed breadcrumb items</span>
</span>
</li>
{/if}
<li
data-slot="item"
data-index={index}
data-state={index === lastIndex ? "current" : undefined}
class={itemClass(index)}
>
{#if index > 0}{@render Separator()}{/if}
{#if index === lastIndex}
<span
data-slot="current"
aria-current="page"
title={item.title}
class={CURRENT_CLASSES}
>
<span class="truncate">{item.label}</span>
</span>
{:else if item.href}
<Link
href={item.href}
data-slot="link"
title={item.title}
class={LINK_CLASSES}
>
<span class="truncate">{item.label}</span>
</Link>
{:else}
<span data-slot="label" title={item.title} class={LABEL_CLASSES}>
<span class="truncate">{item.label}</span>
</span>
{/if}
</li>
{/each}
</ol>
</nav>
{/if}
Related components
- Tabs — handles peer panels or routes instead of parent hierarchy.
- Menu — supplies a real interactive list when ancestor discovery is intentionally required.
- Pagination — navigates pages within one collection rather than locations in the application hierarchy.
- Button — provides a truthful Back action when visit history, not hierarchy, is the intent.