Site Navigation

@use 'sprucecss/scss/spruce' as *;

.site-navigation {
  --inset-block-start: 100%;

  &__btn {
    // Without JavaScript, the menu is always visible and there's nothing to toggle.
    display: none;

    @include breakpoint('md') {
      display: none !important;
    }
  }

  &[data-enhanced] &__btn {
    display: inline-flex;
  }

  &__line {
    @include transition($property: 'opacity, rotate, translate');
    transform-origin: center;
  }

  &__btn[aria-expanded='true'] {
    .site-navigation__line--top {
      rotate: 45deg;
      translate: 0 6px;
    }

    .site-navigation__line--middle {
      opacity: 0;
    }

    .site-navigation__line--bottom {
      rotate: -45deg;
      translate: 0 -6px;
    }
  }

  > ul {
    display: flex;
    flex-wrap: wrap;
    gap: spacer('s') spacer('m');
    list-style: none;
    margin: 0;
    padding: 0;

    @include breakpoint('lg') {
      gap: spacer('l');
    }
  }

  // The collapsible panel on small screens.
  @include breakpoint-down('md') {
    &[data-enhanced] > ul {
      background-color: color('background');
      border-block: config('border-width-base', $display) solid color('border');
      box-shadow: shadow('md');
      flex-direction: column;
      inset-block-start: var(--inset-block-start);
      inset-inline: 0;
      padding: spacer('m') spacer-clamp('m', 'l');
      position: absolute;
      z-index: z-index('dropdown');
    }

    &[data-enhanced] &__btn[aria-expanded='false'] + ul {
      display: none;
    }
  }

  li {
    margin-block: 0;
  }

  // A menu item with a dropdown: the toggle button and the submenu.
  &__item {
    position: relative;
  }

  &__toggle {
    align-items: center;
    background: none;
    border: 0;
    color: color('heading');
    cursor: pointer;
    display: inline-flex;
    font: inherit;
    font-weight: 600;
    gap: spacer('xxs');
    padding: 0;

    &:hover,
    &[aria-expanded='true'] {
      color: color('primary');
    }
  }

  // The parent of the current page is highlighted too.
  &__item:has([aria-current='page']) > &__toggle {
    color: color('primary');
  }

  &__chevron {
    @include transition($property: 'rotate');
    block-size: 0.85em;
    flex-shrink: 0;
    inline-size: 0.85em;
  }

  &__toggle[aria-expanded='true'] &__chevron {
    rotate: 180deg;
  }

  &__submenu {
    @include clear-list;
    background-color: color('surface');
    border: config('border-width-base', $display) solid color('border');
    border-radius: radius('md');
    box-shadow: shadow('lg');
    display: none;
    inset-block-start: calc(100% + #{spacer('xs')});
    inset-inline-start: calc(#{spacer('s')} * -1);
    min-inline-size: 12rem;
    padding: spacer('xxs');
    position: absolute;
    z-index: z-index('dropdown');

    // Bridges the gap above the panel, so it stays open on hover without JavaScript.
    &::before {
      block-size: spacer('xs');
      content: '';
      inset-block-end: 100%;
      inset-inline: 0;
      position: absolute;
    }

    a {
      border-radius: radius('sm');
      display: block;
      padding: spacer('xs') spacer('s');
      white-space: nowrap;

      &:hover {
        background-color: color-alpha('primary', 0.1);
      }
    }
  }

  // Without JavaScript, the submenu opens on hover and focus; with it, the toggle button opens it.
  &:not([data-enhanced]) &__item:is(:hover, :focus-within) > &__submenu,
  &[data-enhanced] &__toggle[aria-expanded='true'] + &__submenu {
    display: block;
  }

  // In the collapsible panel, the submenu is an indented list under its toggle.
  @include breakpoint-down('md') {
    &[data-enhanced] &__submenu {
      background-color: transparent;
      border: 0;
      border-inline-start: config('border-width-base', $display) solid color('border');
      border-radius: 0;
      box-shadow: none;
      margin-block-start: spacer('xs');
      min-inline-size: 0;
      padding: 0 0 0 spacer('xs');
      position: static;

      &::before {
        content: none;
      }
    }
  }

  a {
    color: color('heading');
    font-weight: 600;
    text-decoration: none;

    &:hover {
      color: color('primary');
    }

    &[aria-current='page'] {
      color: color('primary');
      text-decoration: underline;
      text-decoration-thickness: 2px;
      text-underline-offset: 0.4em;
    }
  }
}

@include motion-reduce {
  .site-navigation__line,
  .site-navigation__chevron {
    transition: none;
  }
}
<nav class="site-navigation" aria-label="Main" data-site-navigation>
    <button class="btn btn--primary btn--icon site-navigation__btn" type="button" aria-controls="primary-menu" aria-expanded="false">
        <svg class="btn__icon" aria-hidden="true" fill="none" focusable="false" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path class="site-navigation__line site-navigation__line--top" d="M4 6h16"/><path class="site-navigation__line site-navigation__line--middle" d="M4 12h16"/><path class="site-navigation__line site-navigation__line--bottom" d="M4 18h16"/></svg>
        <span class="sr-only">Menu</span>
    </button>
    <ul class="navigation-menu" id="primary-menu">
        <li><a href="#">Services</a></li>
        <li class="site-navigation__item">
            <button class="site-navigation__toggle" type="button" aria-expanded="false" aria-controls="primary-submenu-products">
                Products
                <svg class="site-navigation__chevron" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m6 9 6 6 6-6"/></svg>
            </button>
            <ul class="site-navigation__submenu" id="primary-submenu-products">
                <li><a href="#">Analytics</a></li>
                <li><a href="#">Automation</a></li>
                <li><a href="#">Integrations</a></li>
                <li><a href="#">Security</a></li>
            </ul>
        </li>
        <li><a href="#" aria-current="page">About</a></li>
        <li><a href="#">Contact</a></li>
    </ul>
</nav>

Site Navigation

The main navigation of a site: a row of links with optional dropdowns on wide screens and a menu button with a collapsible panel on small screens.

Anatomy

  • .site-navigation is a nav labelled "Main"; add data-site-navigation for the script.
  • .site-navigation__btn is the menu button (aria-controls, aria-expanded), with an icon that morphs into a close icon.
  • .navigation-menu is the list of links; the current page has aria-current="page".
  • .site-navigation__item is a menu item with a dropdown: a .site-navigation__toggle button (with a __chevron) and the .site-navigation__submenu list of links.

Accessibility

  • It's a disclosure button, not an ARIA menu: the links stay plain links in the tab order.
  • The button has a visually hidden "Menu" label (earlier versions used "Main", the name of the navigation).
  • Esc closes the panel and returns the focus to the button; it also closes when the focus leaves the navigation or a link is chosen.
  • The dropdowns are disclosures too: the toggle is a button with aria-expanded and aria-controls, and the submenu links follow it in the tab order. Only one is open at a time; Esc closes the open one first (and moves the focus to its toggle), and it also closes when the focus or a click leaves it.
  • The toggle is highlighted when its submenu contains the current page.
  • The current page is marked with aria-current="page" and an underline, not only a color.

Technical Details

  • Progressive enhancement: the script adds data-enhanced, and only then does the CSS hide the links behind the button. Without JavaScript, the links are always visible.
  • The script handles every navigation on the page, and closes the panel when the viewport grows past the md breakpoint (matchMedia change event).
  • The panel is positioned below the header with logical properties (inset-block-start, inset-inline); change --inset-block-start to adjust it.
  • On wide screens, a submenu is a dropdown panel under its toggle; in the small-screen panel, it's an indented list. Without JavaScript, the submenus open on hover and focus (:focus-within).
  • The toggle opens a submenu but doesn't go anywhere itself; if the parent has its own page, add it as the first link of the submenu (like "View all").
  • The animations of the icon and the chevron are turned off for reduced motion.

Documentation

Learn about Spruce CSS through our extensive documentation.

Components

Explore our UI library of accessible components built with Spruce CSS.

Blocks and Pages

Full-width page blocks and complete page templates, ready to copy.

Find us on GitHub

Did you find a bug? Have an idea or a question? Please open an issue to help us develop the project.