Building Responsive Navigation Menus That Respond to Container Width

Build responsive navigation that collapses based on its own container width using container queries, CSS-only toggle patterns, and accessible state management.

Building Responsive Navigation Menus That Respond to Container Width

A navigation menu stuck in a narrow sidebar or a wide header has no idea how much room it really has. The viewport width says nothing about the nav’s available space. Container queries change that. They let the navigation respond to its own container’s width, using container-type and @container to switch between a full flex bar and a collapsed details/summary or popover-based menu. The first technique to reach for is the one that measures the nav itself, not the screen.

Container Query Navigation Breakpoint: the Nav Measures Itself

To build a navigation that collapses when its own container runs out of space, declare container-type: inline-size on the nav or a wrapper around it. This makes the element a size container that @container rules can query. Express the breakpoint in container-relative units like em, not viewport units. The same nav behaves correctly whether it sits in a narrow sidebar or a wide header.

Writing the Breakpoint

.nav-container {
  container-type: inline-size;
}

.nav {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
}

@container (inline-size < 40em) {
  .nav {
    flex-wrap: nowrap;
  }
  .nav__toggle {
    display: block;
  }
  .nav__links {
    display: none;
  }
}

The @container rule with the < operator is the breakpoint. When the container width drops below 40em, the nav switches to a collapsed state. flex-wrap handles the transition gracefully between full and collapsed states, and gap maintains consistent spacing regardless of how many items fit.

CSS-Only Hamburger Menu Pattern: the Details/summary Approach

The CSS-only hamburger menu that requires no JavaScript uses the native HTML details and summary elements. The summary acts as the visible toggle button, and its aria-expanded state is managed automatically by the browser. The hidden attribute on the menu content removes it from the accessibility tree when collapsed. The browser handles keyboard focus order natively.

Markup and Styles

<nav class="nav-container">
  <details class="nav">
    <summary class="nav__toggle">Menu</summary>
    <ul class="nav__links">
      <li><a href="#">Home</a></li>
      <li><a href="#">Destinations</a></li>
      <li><a href="#">Tours</a></li>
      <li><a href="#">About</a></li>
    </ul>
  </details>
</nav>
.nav__links {
  display: none;
}

.nav[open] .nav__links {
  display: block;
}

@container (inline-size >= 40em) {
  .nav__toggle {
    display: none;
  }
  .nav__links {
    display: flex;
  }
}

The details element gives you the toggle state for free: the [open] attribute is set by the browser when the summary is clicked. The accessibility tree removal happens automatically because display: none on the links removes them from what screen readers announce. This keeps the nav usable without any JavaScript dependency.

Popover API Alternative for Collapsed Menus

A more modern alternative uses the popover API. It gives you light-dismiss behaviour that details does not have by default. The popover attribute creates a top-layer element that closes when the user clicks outside it. The inert attribute is applied automatically to the rest of the page while the popover is open.

Popover Markup and Styles

<nav class="nav-container">
  <button popovertarget="nav-popover" aria-controls="nav-popover">Menu</button>
  <ul id="nav-popover" popover class="nav__links">
    <li><a href="#">Home</a></li>
    <li><a href="#">Destinations</a></li>
    <li><a href="#">Tours</a></li>
    <li><a href="#">About</a></li>
  </ul>
</nav>
.nav__links:popover-open {
  display: flex;
  flex-direction: column;
}

@container (inline-size >= 40em) {
  .nav__links {
    display: flex;
    flex-direction: row;
  }
  [popovertarget] {
    display: none;
  }
}

The popover API requires less CSS than the details technique because the browser handles the open state, focus trap, and dismissal. The aria-controls attribute on the toggle button tells assistive technology what the button controls. The popover element itself is exposed correctly in the accessibility tree. Support is Baseline 2023, so it is safe for modern browsers. The details technique remains the better choice if you need to support older Safari versions.

Mobile-First Navigation CSS Technique: Container Queries First, Media Queries Second

Start with the collapsed state as the default. Add the full bar only when the container has enough width. This forces you to treat the hamburger state as the primary experience, not an afterthought.

.nav__links {
  display: none;
}

.nav__toggle {
  display: block;
}

@container (inline-size >= 40em) {
  .nav__links {
    display: flex;
  }
  .nav__toggle {
    display: none;
  }
}

Place this nav inside a sidebar that is 30em wide. The container query collapses it because the container is smaller than 40em, even though the viewport is 1200px. That is the case that actually calls for a media query: when the design intent is genuinely “on small screens, always collapse” regardless of placement. A media query on max-width is the right tool for a site where the nav is always full-width and the breakpoint is a fixed device characteristic.

Media Query Version and Why it is the Wrong Tool for Reusable Navs

The media query version uses @media to switch between the full bar and the collapsed technique. It works when the nav is always the same width relative to the viewport. Drop that nav into a sidebar, a modal, or any container narrower than the viewport, and the media query has no idea. It will show the full bar in a space that cannot fit it.

@media (max-width: 768px) {
  .nav__links {
    display: none;
  }
  .nav__toggle {
    display: block;
  }
}

@media (min-width: 769px) {
  .nav__links {
    display: flex;
  }
  .nav__toggle {
    display: none;
  }
}

The viewport meta tag <meta name="viewport" content="width=device-width, initial-scale=1"> is a requirement for any responsive media query to function on mobile. It does nothing for container queries. The media query is the right choice for the page-level shell: a global header that is always full-width can safely collapse at a fixed viewport breakpoint. The container query is the right choice for any nav that is a reusable component.

@supports Query for Container-Type and the Fallback

Container queries shipped in Blink in Chrome 105 (2022-08-30), WebKit in Safari 16.0 (2022-09-12), and Gecko in Firefox 110 (2023-02-14), making the feature Baseline 2023. Older browsers still exist. The @supports query lets you write a container-query version and a media-query fallback that only applies when container-type is not supported.

Writing the Fallback

.nav__links {
  display: none;
}

.nav__toggle {
  display: block;
}

.nav-container {
  container-type: inline-size;
}

@supports (container-type: inline-size) {
  @container (inline-size >= 40em) {
    .nav__links {
      display: flex;
    }
    .nav__toggle {
      display: none;
    }
  }
}

@supports not (container-type: inline-size) {
  @media (min-width: 769px) {
    .nav__links {
      display: flex;
    }
    .nav__toggle {
      display: none;
    }
  }
}

The fallback is acceptable when the nav is always full-width. In that case the viewport breakpoint is a reasonable proxy for the container width. The @supports not block uses the media query version as the safe default. If the nav is placed in a narrow container that is not full-width, the fallback will fail. That is a design decision you make knowingly when you choose the fallback.

Priority Navigation Pattern CSS: the Overflow Menu

The priority-plus technique shows the most important items in the bar and moves the rest into an overflow menu. The container query decides which items fit based on the container width. The overflow menu holds everything that does not fit.

Overflow Menu Markup and Styles

.nav {
  display: flex;
  flex-wrap: nowrap;
  gap: 1rem;
}

.nav__item {
  flex-shrink: 1;
  white-space: nowrap;
}

.nav__item--more {
  display: none;
}

@container (inline-size < 40em) {
  .nav__item--more {
    display: block;
  }
}
<nav class="nav-container">
  <ul class="nav">
    <li><a href="#">Home</a></li>
    <li><a href="#">Destinations</a></li>
    <li><a href="#">Tours</a></li>
    <li><a href="#">About</a></li>
    <li class="nav__item--more"><a href="#">More</a></li>
  </ul>
</nav>

The overflow menu is a better choice than the full hamburger for navigation bars with a known set of priorities. Do not use flex-wrap here because the design intent is to never wrap. The container query reveals the “More” button only when the container is too narrow to show all items. gap keeps the spacing consistent between the visible items and the overflow button.

Checkbox and Label Toggle Pattern

Before container queries and the popover API, the checkbox and label toggle was the standard CSS-only way to build a hamburger menu. It uses an input[type="checkbox"] with a sibling selector to toggle the nav visibility. It still works as a fallback for browsers that do not support :has() or popover.

Checkbox Toggle Markup and Styles

<input type="checkbox" id="menu-toggle" class="menu-toggle" hidden>
<label for="menu-toggle" class="menu-label">Menu</label>
<nav class="nav">
  <ul>
    <li><a href="#">Home</a></li>
    <li><a href="#">Destinations</a></li>
    <li><a href="#">Tours</a></li>
    <li><a href="#">About</a></li>
  </ul>
</nav>
.menu-toggle:checked ~ .nav {
  display: block;
}

.menu-toggle:not(:checked) ~ .nav {
  display: none;
}

.menu-label {
  display: none;
}

@media (max-width: 768px) {
  .menu-label {
    display: block;
  }
}

The checkbox input must have the hidden attribute to be removed from the accessibility tree, and the label must have discernible text or aria-label. The sibling combinator ~ requires the input to come before the nav in the DOM and to share the same parent. The biggest mistakes: using display: none on the menu, which removes it from the accessibility tree and prevents keyboard navigation, and placing the checkbox outside the stacking context, which breaks the sibling selector path.

Mistake 1: Display: None Removes the Menu from the Accessibility Tree

The most common mistake in responsive navigation is using display: none on the menu content to hide it. This removes the links from the accessibility tree entirely. Screen reader users cannot navigate to them even when the menu is functionally open via JavaScript or a checkbox toggle. The WCAG 2.4.3 focus order criterion requires that focus moves in a logical sequence. display: none breaks that because the links do not exist in the accessibility tree at all.

The fix: use the hidden attribute for the closed state and remove it for the open state. The hidden attribute has the same effect as display: none on the accessibility tree, but it is the browser-native way to express that a subtree is not currently relevant. When you toggle the hidden attribute off, the content returns to the accessibility tree and keyboard focus can reach it.

Mistake 2: Checkbox Outside the Stacking Context Breaks the Sibling Selector

The checkbox and label toggle depends on the sibling combinator. If the checkbox input is placed outside the parent that contains the nav, or if any ancestor creates a new stacking context, the ~ selector will not match. The toggle will silently fail. The checkbox must be a sibling of the nav, and both must share the same parent.

The inert attribute is the modern replacement for the accessibility tree removal that display: none and hidden achieve. When you use the popover API, the inert attribute is applied automatically to the rest of the page while the popover is open. This prevents background content from being focusable. It is a cleaner technique than the checkbox because the browser manages the focus trap and the accessibility tree state.

The :has() Toggle Pattern

The :has() relational pseudo-class lets you toggle the nav state based on a checkbox input without requiring the sibling combinator. The selector matches an element based on its descendants. Write .nav:has(~ .menu-toggle:checked) to select the nav when the checkbox is checked, even if the checkbox is nested deeper in the DOM.

.nav:has(~ .menu-toggle:checked) {
  display: block;
}

.nav:not(:has(~ .menu-toggle:checked)) {
  display: none;
}

The accepted fallback for :has()-based navigation: write the checkbox and label technique as the default, then use @supports (selector(:has(*))) to add the :has() rules on top. The checkbox and label remains the non-:has() default. The :has() version is an enhancement for browsers that support it. The older technique replaced by this is JavaScript event listeners on a hamburger button toggling a class on the body or nav.

FAQ: Responsive Navigation Menus with Container Queries

What is the difference between a container query and a media query?

A container query responds to the size of a container that has container-type declared on it. A media query responds to the viewport or device characteristics. Container queries are scoped to the component. Media queries are scoped to the screen.

Can I use container queries in Safari?

Yes. Safari 16.0 shipped container query engine support on 2022-09-12. The feature is Baseline 2023. Older Safari versions on device-locked iOS require the @supports fallback.

What is the exact syntax for a container query breakpoint?

Use @container (inline-size < 40em) { } for a max-width breakpoint or @container (inline-size >= 40em) { } for a min-width breakpoint. The container must have container-type: inline-size declared.

Why does my container query not respond?

The most common cause: no container-type declared on the container. Without it, there is no container to measure. Check that the container is not display: contents and that no ancestor has a container-type that overrides it.

Table: Container Query Vs. Media Query for Navigation

Feature Container Query Media Query
Measures Container width Viewport width
Scope Component-level Page-level
Reusable in sidebar Yes No
Requires container-type Yes No
Breakpoint unit em, rem, px relative to container px, em, rem relative to viewport
Fallback needed for old browsers Yes No
Best use case Reusable nav component Fixed full-width header
Accessibility tree behaviour Depends on display or hidden Depends on display or hidden
Keyboard focus order Managed by browser or manual Managed by browser or manual

The Container Query Engine Shipment Timeline

The container query engine shipped in Blink in Chrome 105 on 2022-08-30, in WebKit in Safari 16.0 on 2022-09-12, and in Gecko in Firefox 110 on 2023-02-14. This makes container queries Baseline 2023, available in the vast majority of browsers people use today. The remaining gap covers users on older device-locked browsers, such as iOS Safari on unsupported devices or Android WebView in apps that do not update. No reliable survey captures that slice precisely; it is small but not zero.

The real interop gap: container units (cqw and cqh) have bugs in older versions of Safari that shipped container queries. Style queries, which respond to the computed value of a custom property on the container, shipped later than size container queries. If you use style queries, you need a separate @supports check.

The Single Most Practical Thing to Do

Declare container-type: inline-size on your nav today. Write a @container (inline-size < 40em) rule that collapses the menu into a details/summary pattern. Keep a @supports not (container-type: inline-size) media query fallback for the small percentage of browsers that do not support it. The container query works in a narrow sidebar and a wide header. The fallback covers the rare case where it cannot.

Meta

The fallback is acceptable when the nav is always full-width, because in that case the viewport breakpoint is a reasonable proxy for the container width.”