Skip to content
Learn Measure Blog Case studies About
  • Home
  • All patterns
  • Component patterns

Breadcrumbs

Jun 1, 2022

This pattern shows how to create a responsive and accessible breadcrumbs component for users to navigate your site.

Full article · Video on YouTube · Source on Github

<nav class="breadcrumbs" role="navigation">
  <a href="./home/">
    <span class="crumbicon">
      <svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
        <use href="#icon-home" />
      </svg>
    </span>
    <span class="home-label">Home</span>
  </a>

  <span class="crumb-separator" aria-hidden="true">»</span>

  <span class="crumb">
    <a aria-current="page">Page A</a>
    <span class="crumbicon">
      <svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
        <use href="#icon-dropdown-arrow" />
      </svg>
      <select class="disguised-select" title="Navigate to another page">
        <option selected>Page A</option>
        <option>Page B</option>
        <option>Page C</option>
      </select>
    </span>
  </span>
</nav>

<svg style="display: none;">

  <symbol id="icon-home">
    <title>A home icon</title>
    <path
      d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
  </symbol>

  <symbol id="icon-dropdown-arrow">
    <title>A down arrow</title>
    <path d="M19 9l-7 7-7-7" />
  </symbol>

</svg>
const crumbs         = document.querySelectorAll('.breadcrumbs select')
const allowedKeys    = new Set(['Tab', 'Enter', ' '])
const preventedKeys  = new Set(['ArrowUp', 'ArrowDown'])

// watch crumbs for *full* changes,
// ensures it's not a user exploring options via keyboard
crumbs.forEach(nav => {
  let ignoreChange = false

  nav.addEventListener('change', e => {
    if (ignoreChange) return

    const option = e.target
    const choice = option.value
    const crumb = option.closest('.crumb')

    // flag crumb so adjacent siblings can be hidden
    crumb.classList.add('tree-changed')

    // update crumb text to reflect the user's choice
    crumb.querySelector(':scope > a').textContent = choice

    routePage(choice)
  })

  nav.addEventListener('keydown', ({ key }) => {
    if (preventedKeys.has(key))
      ignoreChange = true
    else if (allowedKeys.has(key))
      ignoreChange = false
  })
})

const routePage = route => {
  console.info('change path to: ', route)
  // change entire URL (window.location)
  // or 
  // use your favorite clientside framework's router
}
.breadcrumbs {
  --nav-gap: 2ch;

  display: flex;
  align-items: center;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scroll-snap-type: x proximity;

  gap: var(--nav-gap);
  padding: calc(var(--nav-gap) / 2);
  scroll-padding-inline: calc(var(--nav-gap) / 2);

  & > a:first-of-type:not(.crumb) {
    display: inline-flex;
    align-items: center;
    gap: calc(var(--nav-gap) / 4);

    @media (width <= 480px) { & > .home-label {
      display: none;
    }}
  }

  & a {
    text-underline-offset: .25em;
    outline-offset: 3px;

    /* fix Safari inaccessible dark color scheme links */
    /* https://bugs.webkit.org/show_bug.cgi?id=226893 */
    @media (prefers-color-scheme: dark) {
      @supports (-webkit-hyphens:none) { &[href] {
        color: hsl(240 100% 81%);
      }}
    }
  }

  & > .crumb:last-of-type {
    scroll-snap-align: end;
  }

  @supports (-webkit-hyphens:none) {
    scroll-snap-type: none;
  }
}

.crumb {
  display: inline-flex;
  align-items: center;
  gap: calc(var(--nav-gap) / 4);

  & > a {
    white-space: nowrap;

    &[aria-current="page"] {
      font-weight: bold;
    }
  }

  &.tree-changed ~ * {
    display: none;
  }
}

.crumb-separator {
  color: ButtonText;
}

.disguised-select {
  inline-size: 100%;
  block-size: 100%;
  opacity: .01;
  font-size: min(100%, 16px);
}

.crumbicon {
  --size: 3ch;

  display: grid;
  grid: [stack] var(--size) / [stack] var(--size);
  place-items: center;
  border-radius: 50%;

  --icon-shadow-size: 0px;
  box-shadow: inset 0 0 0 var(--icon-shadow-size) currentColor;
  
  @media (--motionOK) { & {
    transition: box-shadow .2s ease;
  }}

  @nest .crumb:is(:focus-within, :hover) > & {
    --icon-shadow-size: 1px;
  }

  @nest .crumb > &:is(:focus-within, :hover) {
    --icon-shadow-size: 2px;

    & svg {
      stroke-width: 2px;
    }
  }

  & > * {
    grid-area: stack;
  }

  & > svg {
    max-block-size: 100%;
    margin: calc(var(--nav-gap) / 4);

    stroke: currentColor;
    fill: none;
    stroke-linecap: round;
    stroke-linejoin: round;
    stroke-width: 1px;
  }
}
Open demo
Last updated: Jun 1, 2022 — Improve article
Share
subscribe

Contribute

  • File a bug
  • View source

Related content

  • developer.chrome.com
  • Chrome updates
  • Web Fundamentals
  • Case studies
  • Podcasts
  • Shows

Connect

  • Twitter
  • YouTube
  • Google Developers
  • Chrome
  • Firebase
  • Google Cloud Platform
  • All products
  • Terms & Privacy
  • Community Guidelines

Except as otherwise noted, the content of this page is licensed under the Creative Commons Attribution 4.0 License, and code samples are licensed under the Apache 2.0 License. For details, see the Google Developers Site Policies.