GrBreadcrumbs

Package: @feugene/granularitycoreGroup: navigation

Shows where the user is and lets them step back up the path.

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • the nesting is real — a catalogue, a file system, sections of documentation: the path shows where the user is;
  • the path is longmaxItems and autoCollapse fold the middle, leaving the beginning and the end;
  • every level can be returned to — each ancestor is a link, the last item is the current page;
  • the router is already connected — the links are drawn by GrLink, so the way of navigating is the same as everywhere.

When to take something else

NeedTake
Steps of a process rather than levelsGrTimeline
Sections of one levelGrTabs
The main navigation of the applicationGrSidebar / GrNavbar
A single step backGrButton / GrLink

A flat site needs no breadcrumbs: a path of one level says nothing and takes up space. They pay off where the user can end up on the fourth level and not remember the way.

The router

<GrBreadcrumbs :as="RouterLink" :items="items" />
const items = [
  { label: 'Projects', to: '/projects' },
  { label: 'Granularity', to: '/projects/granularity' },
  { label: 'Settings' },
]

as sets the link component for every item at once, and to travels into it as a prop through the attrs of GrLink. Without as an item with href renders as an ordinary <a>.

The current page

The last item is not a link and is declared aria-current="page": it is that attribute that answers the screen reader’s question “where am I”. If the current page has to stay clickable (reloading a section, an anchor), switch on linkCurrentaria-current is kept in that case.

An intermediate item can be switched off (disabled: true) — it becomes text, but does not get aria-current.

A long path

<GrBreadcrumbs :items="path" :max-items="4" :items-before-collapse="1" :items-after-collapse="2" />

The middle folds into a ”…” button, and as many levels stay at the edges as itemsBeforeCollapse/itemsAfterCollapse ask for. The tail matters more than the head: “where am I now” is read from the right, so when there is not enough room it is the middle that is sacrificed.

The component will not hide a single item — the button would take up just as much room.

A click on the ellipsis expands the path in place, without a dropdown menu. The button disappears in the process, so the focus is moved to the first expanded item: otherwise it would end up on <body>. Changing items (a new page) folds the path again.

The layout can also be computed from the outside — resolveBreadcrumbsLayout is exported as a pure function.

Folding by the room available

<GrBreadcrumbs :items="path" auto-collapse />

maxItems counts items, and on a narrow screen a number is a poor predictor: three short levels fit, two long ones do not. autoCollapse measures: the path becomes single-line, and the middle goes under the ”…” by exactly as much as does not fit.

What changes along with the mode:

without the propautoCollapse
a long pathwraps onto a second linestays on one line
the thresholdmaxItems (the number of items)the width of the container
the tailitemsAfterCollapseas much as fits, but not less than one

The head is not squeezed: itemsBeforeCollapse is usually the root item, and it is cheap. The tail is not squeezed below one item: the last one answers the question “where am I now”, and the path “Home / …” is useless.

The props are compatible: maxItems remains a hard ceiling, and the width squeezes further.

The first frame in this mode is the full path: the widths of the labels are taken from it, and there is nowhere to get them before the render. After that the recalculation runs on ResizeObserver and costs one arithmetic operation — the labels do not depend on the width of the container.

The arithmetic is separated out: resolveBreadcrumbsFit is exported as a pure function if the decision has to be made from the outside.

The separator and the size

separator (/ by default) and size are read from GrConfigProvider:

<GrConfigProvider :component-defaults="{ GrBreadcrumbs: { separator: '›' } }">

The separator is decorative — it lives in a list item of its own with aria-hidden: the structure of the path is told to the screen reader by the list itself, and it would read the ”/” aloud.

The icon of an item

The icon field is decorative (aria-hidden) and accepts a Vue component or an icon class from your UnoCSS build (see “Icons”).

iconOnly shows only the icon — the classic “house” at the start of the path:

<GrBreadcrumbs :items="[{ label: 'Home', href: '/', icon: 'i-lucide-house', iconOnly: true }, …]" />

The label is not thrown away and does not move into aria-label; it is hidden with sr-only. The reason lies in who reads the path: a search engine and a screen reader read it as text, and an item without a name is empty for a blind user — they hear “link” without knowing where to.

Without icon the field is ignored, and in development the component warns: hiding the label without showing anything in its place means erasing the item — in the markup everything is in place, and the loss is visible to the eye alone.

Slots

SlotWhat it replaces
itemthe content of an item (item, index, isCurrent)
separatorthe separator
ellipsisthe expand button (hiddenCount, expand)

Playground 10

Loading…

Code
<GrBreadcrumbs />

Install

npm i @feugene/granularity

Import

import { GrBreadcrumbs } from '@feugene/granularity/components/GrBreadcrumbs'

API

Props

PropTypedefaultDescription
size"xs" | "sm" | "md" | "lg" | undefinedundefined
ariaLabelstring | undefinedundefinedThe name of the landmark. If it is not set, it comes from the locale.
asGrBreadcrumbsLinkComponent | undefinedundefinedThe link component for every item: `RouterLink`, `NuxtLink`, Inertia’s `Link`.
separatorstring | undefinedundefinedThe separator between items. Decorative: it is not read to a screen reader.
maxItemsnumber | undefinedundefinedFrom how many items the middle folds into a "…". `0` or unset — show the whole path.
itemsBeforeCollapsenumber | undefined1How many items to leave at the start when folding.
itemsAfterCollapsenumber | undefined1How many items to leave at the end when folding.
linkCurrentboolean | undefinedfalseThe last item stays a link (the current page is clickable).
currentIndexnumber | undefinedundefinedWhich item is the current page. Unset — the last one. `-1` means the current page is not in the path: its name stands in the `h1`, and the path shows the ancestors only. Then nobody gets `aria-current`, and the last item stays an ordinary link.
expandLabelstring | undefinedundefinedThe i18n label of the button that expands a folded path.
autoCollapseboolean | undefinedfalseFold by the available width rather than by the number of items. The path becomes single-line, and the middle goes under the "…" by exactly as much as does not fit. Without the prop the behaviour is as before: the list wraps onto a second line, and the threshold is set by `maxItems`. Together the props are compatible — `maxItems` remains a hard ceiling, and the width squeezes further.
itemsrequiredGrBreadcrumbItem[]The path from the root to the current page. The last item is the current page.

Slots

SlotTypeDescription
item{ item: GrBreadcrumbItem; index: number; isCurrent: boolean; }The content of an item as a whole (the icon and the label).
separatoranyThe separator between items.
ellipsis{ hiddenCount: number; expand: () => void; }The button that expands the folded middle.

Examples 4

Collapsing by available width

Auto Collapse
<script setup lang="ts">
import { ref } from 'vue'

import { GrBreadcrumbs, GrSegmented, type GrBreadcrumbItem } from '@feugene/granularity'

const path: GrBreadcrumbItem[] = [
  { label: 'Storage', href: '#/storage' },
  { label: 'Workspaces', href: '#/storage/workspaces' },
  { label: 'Design system', href: '#/storage/workspaces/design-system' },
  { label: 'Releases', href: '#/storage/workspaces/design-system/releases' },
  { label: '0.15.0', href: '#/storage/workspaces/design-system/releases/0-15-0' },
  { label: 'CHANGELOG.md' },
]

// Ширина контейнера, а не окна: схлопывание считается по доступному месту,
// поэтому увидеть его можно не трогая размер браузера.
const width = ref('520')
const widths = [
  { value: '260', label: 'Narrow' },
  { value: '380', label: 'Medium' },
  { value: '520', label: 'Wide' },
]
</script>

<template>
  <div class="grid gap-4">
    <GrSegmented v-model="width" :options="widths" size="sm" class="justify-self-start" />

    <div
      class="rounded-[var(--gr-radius-md)] border border-dashed border-[var(--gr-brd)] p-3"
      :style="{ width: `${width}px`, maxWidth: '100%' }"
    >
      <GrBreadcrumbs :items="path" auto-collapse />
    </div>
  </div>
</template>

Path to the current page

Basic
<script setup lang="ts">
import { GrBreadcrumbs, type GrBreadcrumbItem } from '@feugene/granularity'

const path: GrBreadcrumbItem[] = [
  { label: 'Dashboard', href: '#/dashboard' },
  { label: 'Projects', href: '#/projects' },
  { label: 'Granularity', href: '#/projects/granularity' },
  { label: 'Settings' },
]
</script>

<template>
  <GrBreadcrumbs :items="path" />
</template>

Long path with a collapsed middle

Collapsed
<script setup lang="ts">
import { GrBreadcrumbs, type GrBreadcrumbItem } from '@feugene/granularity'

// Длинный путь из файлового менеджера: середина сворачивается, начало и конец
// остаются на виду.
const path: GrBreadcrumbItem[] = [
  { label: 'Storage', href: '#/storage' },
  { label: 'Workspaces', href: '#/storage/workspaces' },
  { label: 'Design system', href: '#/storage/workspaces/design-system' },
  { label: 'Releases', href: '#/storage/workspaces/design-system/releases' },
  { label: '0.15.0', href: '#/storage/workspaces/design-system/releases/0-15-0' },
  { label: 'CHANGELOG.md' },
]
</script>

<template>
  <GrBreadcrumbs
    :items="path"
    :max-items="4"
    :items-before-collapse="1"
    :items-after-collapse="2"
  />
</template>

Icons, custom separator and size

Первый пункт второго и третьего пути — iconOnly: видна только иконка. Подпись при этом не выброшена, а спрятана sr-only — путь читают и поиском, и диктором, и «домик» без имени сделал бы первый пункт для незрячего пустым.

Icons
<script setup lang="ts">
import { GrBreadcrumbs, type GrBreadcrumbItem } from '@feugene/granularity'

const path: GrBreadcrumbItem[] = [
  { label: 'Home', href: '#/', icon: 'i-lucide-house' },
  { label: 'Team', href: '#/team', icon: 'i-lucide-users' },
  { label: 'Ada Lovelace', icon: 'i-lucide-user' },
]

/**
 * Все четыре вида пункта в одном пути: пункт-иконка, ссылка без иконки, ссылка
 * с иконкой и обычный пункт без ссылки.
 */
const mixed: GrBreadcrumbItem[] = [
  { label: 'Главная', href: '#/', icon: 'i-lucide-house', iconOnly: true },
  { label: 'Проекты', href: '#/projects' },
  { label: 'Гранулярность', href: '#/projects/granularity', icon: 'i-lucide-box' },
  { label: 'Настройки' },
]

/** Короткий путь второго уровня: домик и страница. */
const short: GrBreadcrumbItem[] = [
  { label: 'Главная', href: '#/', icon: 'i-lucide-house', iconOnly: true },
  { label: 'Профиль' },
]
</script>

<template>
  <div class="grid gap-5">
    <GrBreadcrumbs :items="path" separator="" size="lg" />

    <GrBreadcrumbs :items="mixed" size="lg" />

    <GrBreadcrumbs :items="short" size="lg" />

    <p class="text-sm text-[var(--gr-muted-fg)]">
      Первый пункт второго и третьего пути — <code>iconOnly</code>: видна только иконка. Подпись при
      этом не выброшена, а спрятана <code>sr-only</code> — путь читают и поиском, и диктором, и
      «домик» без имени сделал бы первый пункт для незрячего пустым.
    </p>
  </div>
</template>

Accessibility

APG pattern
breadcrumb

Full keyboard contract of the package

Component documentationAll components