Skip to content

Demo

This requires the following components to be installed:

This requires the following utils to be installed:

This requires the following types to be installed:

This requires the following theme to be installed:

Component

Breadcrumb.vue
vue
<!-- eslint-disable vue/block-tag-newline -->
<script lang="ts">
import type { AvatarProps } from '@/ui/components/Avatar.vue'
import type { LinkProps } from '@/ui/components/Link.vue'
import type { DynamicSlots } from '@/ui/utils/utils'
import Avatar from '@/ui/components/Avatar.vue'
import Icon from '@/ui/components/Icon.vue'
import Link from '@/ui/components/Link.vue'
import { separatorIcon } from '@/ui/icons'
import theme from '@/ui/theme/breadcrumb'
import { get } from '@/ui/utils/get'
import { pickLinkProps } from '@/ui/utils/pick-link-props'
import { Primitive } from 'reka-ui'
import { tv } from 'tailwind-variants'

const breadcrumb = tv(theme)

export interface BreadcrumbItem extends Omit<LinkProps, 'raw' | 'custom'> {
  label?: string
  icon?: string
  avatar?: AvatarProps
  slot?: string
  [key: string]: any
}

export interface BreadcrumbProps<T extends BreadcrumbItem = BreadcrumbItem> {
  as?: any
  items?: T[]
  labelKey?: string
  class?: any
  ui?: Partial<typeof breadcrumb.slots>
}

type SlotProps<T extends BreadcrumbItem> = (props: { item: T, index: number, active?: boolean }) => any

export type BreadcrumbSlots<T extends BreadcrumbItem = BreadcrumbItem> = {
  'item': SlotProps<T>
  'item-leading': SlotProps<T>
  'item-label': SlotProps<T>
  'item-trailing': SlotProps<T>
  'separator': any
} & DynamicSlots<T, 'leading' | 'label' | 'trailing', { index: number, active?: boolean }>
</script>

<script setup lang="ts" generic="T extends BreadcrumbItem">
const props = withDefaults(defineProps<BreadcrumbProps<T>>(), {
  as: 'nav',
  labelKey: 'label',
})
const slots = defineSlots<BreadcrumbSlots<T>>()

const ui = breadcrumb()
</script>

<template>
  <Primitive :as="as" aria-label="breadcrumb" :class="ui.root({ class: [props.class, props.ui?.root] })">
    <ol :class="ui.list({ class: props.ui?.list })">
      <template v-for="(item, index) in items" :key="index">
        <li :class="ui.item({ class: props.ui?.item })">
          <Link v-bind="pickLinkProps({ ...item, as: 'span' })" :aria-current="item.active ? 'page' : ''" :class="ui.link({ class: [props.ui?.link, item.class], active: index === items!.length - 1, disabled: !!item.disabled, href: !!item.href })" raw>
            <slot :name="((item.slot || 'item') as keyof BreadcrumbSlots<T>)" :item="item" :index="index">
              <slot :name="((item.slot ? `${item.slot}-leading` : 'item-leading') as keyof BreadcrumbSlots<T>)" :item="item" :active="index === items!.length - 1" :index="index">
                <Icon v-if="item.icon" :name="item.icon" :class="ui.linkLeadingIcon({ class: props.ui?.linkLeadingIcon, active: index === items!.length - 1 })" />
                <Avatar v-else-if="item.avatar" :size="((props.ui?.linkLeadingAvatarSize || ui.linkLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.linkLeadingAvatar({ class: props.ui?.linkLeadingAvatar, active: index === items!.length - 1 })" />
              </slot>

              <span v-if="get(item, props.labelKey as string) || !!slots[(item.slot ? `${item.slot}-label` : 'item-label') as keyof BreadcrumbSlots<T>]" :class="ui.linkLabel({ class: props.ui?.linkLabel })">
                <slot :name="((item.slot ? `${item.slot}-label` : 'item-label') as keyof BreadcrumbSlots<T>)" :item="item" :active="index === items!.length - 1" :index="index">
                  {{ get(item, props.labelKey as string) }}
                </slot>
              </span>

              <slot :name="((item.slot ? `${item.slot}-trailing` : 'item-trailing') as keyof BreadcrumbSlots<T>)" :item="item" :active="index === items!.length - 1" :index="index" />
            </slot>
          </Link>
        </li>

        <li v-if="index < items!.length - 1" role="presentation" aria-hidden="true" :class="ui.separator({ class: props.ui?.separator })">
          <slot name="separator">
            <Icon :name="separatorIcon" :class="ui.separatorIcon({ class: props.ui?.separatorIcon })" />
          </slot>
        </li>
      </template>
    </ol>
  </Primitive>
</template>
Breadcrumb.vue
vue
<!-- eslint-disable vue/block-tag-newline -->
<script lang="ts">
import type { AvatarProps } from '@/UI/Components/Avatar.vue'
import type { LinkProps } from '@/UI/Components/Link.vue'
import type { DynamicSlots } from '@/UI/Utils/utils'
import Avatar from '@/UI/Components/Avatar.vue'
import Icon from '@/UI/Components/Icon.vue'
import Link from '@/UI/Components/Link.vue'
import { separatorIcon } from '@/UI/icons'
import theme from '@/UI/Theme/breadcrumb'
import { get } from '@/UI/Utils/get'
import { pickLinkProps } from '@/UI/Utils/pick-link-props'
import { Primitive } from 'reka-ui'
import { tv } from 'tailwind-variants'

const breadcrumb = tv(theme)

export interface BreadcrumbItem extends Omit<LinkProps, 'raw' | 'custom'> {
  label?: string
  icon?: string
  avatar?: AvatarProps
  slot?: string
  [key: string]: any
}

export interface BreadcrumbProps<T extends BreadcrumbItem = BreadcrumbItem> {
  as?: any
  items?: T[]
  labelKey?: string
  class?: any
  ui?: Partial<typeof breadcrumb.slots>
}

type SlotProps<T extends BreadcrumbItem> = (props: { item: T, index: number, active?: boolean }) => any

export type BreadcrumbSlots<T extends BreadcrumbItem = BreadcrumbItem> = {
  'item': SlotProps<T>
  'item-leading': SlotProps<T>
  'item-label': SlotProps<T>
  'item-trailing': SlotProps<T>
  'separator': any
} & DynamicSlots<T, 'leading' | 'label' | 'trailing', { index: number, active?: boolean }>
</script>

<script setup lang="ts" generic="T extends BreadcrumbItem">
const props = withDefaults(defineProps<BreadcrumbProps<T>>(), {
  as: 'nav',
  labelKey: 'label',
})
const slots = defineSlots<BreadcrumbSlots<T>>()

const ui = breadcrumb()
</script>

<template>
  <Primitive :as="as" aria-label="breadcrumb" :class="ui.root({ class: [props.class, props.ui?.root] })">
    <ol :class="ui.list({ class: props.ui?.list })">
      <template v-for="(item, index) in items" :key="index">
        <li :class="ui.item({ class: props.ui?.item })">
          <Link v-bind="pickLinkProps({ ...item, as: 'span' })" :aria-current="item.active ? 'page' : ''" :class="ui.link({ class: [props.ui?.link, item.class], active: index === items!.length - 1, disabled: !!item.disabled, href: !!item.href })" raw>
            <slot :name="((item.slot || 'item') as keyof BreadcrumbSlots<T>)" :item="item" :index="index">
              <slot :name="((item.slot ? `${item.slot}-leading` : 'item-leading') as keyof BreadcrumbSlots<T>)" :item="item" :active="index === items!.length - 1" :index="index">
                <Icon v-if="item.icon" :name="item.icon" :class="ui.linkLeadingIcon({ class: props.ui?.linkLeadingIcon, active: index === items!.length - 1 })" />
                <Avatar v-else-if="item.avatar" :size="((props.ui?.linkLeadingAvatarSize || ui.linkLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.linkLeadingAvatar({ class: props.ui?.linkLeadingAvatar, active: index === items!.length - 1 })" />
              </slot>

              <span v-if="get(item, props.labelKey as string) || !!slots[(item.slot ? `${item.slot}-label` : 'item-label') as keyof BreadcrumbSlots<T>]" :class="ui.linkLabel({ class: props.ui?.linkLabel })">
                <slot :name="((item.slot ? `${item.slot}-label` : 'item-label') as keyof BreadcrumbSlots<T>)" :item="item" :active="index === items!.length - 1" :index="index">
                  {{ get(item, props.labelKey as string) }}
                </slot>
              </span>

              <slot :name="((item.slot ? `${item.slot}-trailing` : 'item-trailing') as keyof BreadcrumbSlots<T>)" :item="item" :active="index === items!.length - 1" :index="index" />
            </slot>
          </Link>
        </li>

        <li v-if="index < items!.length - 1" role="presentation" aria-hidden="true" :class="ui.separator({ class: props.ui?.separator })">
          <slot name="separator">
            <Icon :name="separatorIcon" :class="ui.separatorIcon({ class: props.ui?.separatorIcon })" />
          </slot>
        </li>
      </template>
    </ol>
  </Primitive>
</template>

Theme

breadcrumb.ts
ts
export default {
  slots: {
    root: '',
    list: '',
    item: '',
    link: '',
    linkLeadingIcon: '',
    linkLeadingAvatar: '',
    linkLeadingAvatarSize: '',
    linkLabel: '',
    separator: '',
    separatorIcon: '',
  },
  variants: {
    active: {
      true: {
        link: '',
      },
      false: {
        link: '',
      },
    },
    disabled: {
      true: {
        link: '',
      },
    },
    href: {
      true: '',
    },
  },
  compoundVariants: [],
}
View Nuxt UI theme
breadcrumb.ts
ts
export default {
  slots: {
    root: 'relative min-w-0',
    list: 'flex items-center gap-1.5',
    item: 'flex min-w-0',
    link: 'group relative flex items-center gap-1.5 text-sm min-w-0 focus-visible:outline-primary',
    linkLeadingIcon: 'shrink-0 size-5',
    linkLeadingAvatar: 'shrink-0',
    linkLeadingAvatarSize: '2xs',
    linkLabel: 'truncate',
    separator: 'flex',
    separatorIcon: 'shrink-0 size-5 text-muted',
  },
  variants: {
    active: {
      true: {
        link: 'text-primary font-semibold',
      },
      false: {
        link: 'text-muted font-medium',
      },
    },
    disabled: {
      true: {
        link: 'cursor-not-allowed opacity-75',
      },
    },
    href: {
      true: '',
    },
  },
  compoundVariants: [
    {
      disabled: false,
      active: false,
      href: true,
      class: {
        link: 'hover:text-default transition-colors',
      },
    },
  ],
}

Test

To test this component, you can use the following test file:

Breadcrumb.test.ts
ts
import Breadcrumb from '@/ui/components/Breadcrumb.vue'
import { render } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'

describe('breadcrumb', () => {
  const items = [{
    label: 'Home',
    avatar: {
      src: 'https://github.com/vue.png',
    },
    href: '/',
  }, {
    label: 'Components',
    icon: 'i-lucide-box',
    disabled: true,
  }, {
    label: 'Breadcrumb',
    href: '/components/breadcrumb',
    icon: 'i-lucide-link',
    slot: 'custom',
  }]

  const props = { items }

  it.each([
    // Props
    ['with items', { props }],
    ['with labelKey', { props: { ...props, labelKey: 'icon' } }],
    ['with as', { props: { ...props, as: 'div' } }],
    ['with class', { props: { ...props, class: 'w-48' } }],
    ['with ui', { props: { ...props, ui: { link: 'font-bold' } } }],
    // Slots
    ['with item slot', { props, slots: { item: () => 'Item slot' } }],
    ['with item-leading slot', { props, slots: { 'item-leading': () => 'Item leading slot' } }],
    ['with item-label slot', { props, slots: { 'item-label': () => 'Item label slot' } }],
    ['with item-trailing slot', { props, slots: { 'item-trailing': () => 'Item trailing slot' } }],
    ['with custom slot', { props, slots: { custom: () => 'Custom slot' } }],
    ['with separator slot', { props, slots: { separator: () => '/' } }],
  ])('renders %s correctly', (name, options) => {
    const { html } = render(Breadcrumb, options)

    expect(html()).toMatchSnapshot()
  })
})
Breadcrumb.test.ts
ts
import Breadcrumb from '@/UI/Components/Breadcrumb.vue'
import { render } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'

describe('breadcrumb', () => {
  const items = [{
    label: 'Home',
    avatar: {
      src: 'https://github.com/vue.png',
    },
    href: '/',
  }, {
    label: 'Components',
    icon: 'i-lucide-box',
    disabled: true,
  }, {
    label: 'Breadcrumb',
    href: '/components/breadcrumb',
    icon: 'i-lucide-link',
    slot: 'custom',
  }]

  const props = { items }

  it.each([
    // Props
    ['with items', { props }],
    ['with labelKey', { props: { ...props, labelKey: 'icon' } }],
    ['with as', { props: { ...props, as: 'div' } }],
    ['with class', { props: { ...props, class: 'w-48' } }],
    ['with ui', { props: { ...props, ui: { link: 'font-bold' } } }],
    // Slots
    ['with item slot', { props, slots: { item: () => 'Item slot' } }],
    ['with item-leading slot', { props, slots: { 'item-leading': () => 'Item leading slot' } }],
    ['with item-label slot', { props, slots: { 'item-label': () => 'Item label slot' } }],
    ['with item-trailing slot', { props, slots: { 'item-trailing': () => 'Item trailing slot' } }],
    ['with custom slot', { props, slots: { custom: () => 'Custom slot' } }],
    ['with separator slot', { props, slots: { separator: () => '/' } }],
  ])('renders %s correctly', (name, options) => {
    const { html } = render(Breadcrumb, options)

    expect(html()).toMatchSnapshot()
  })
})

Contributors

barbapapazes

Changelog

45495 - feat: breadcrumb (#147) on 2/14/2025