Breadcrumbs

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

HTML

<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>

CSS


        .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;
  }
}
        

JS


        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
}