Responsive Navigation Without Media Queries Using Container Queries

A navigation bar is the component that most exposes the weakness of viewport breakpoints. The same markup might live in a full-width page header, a constrained 320px sidebar, or a dashboard card, yet a @media rule applies one set of breakpoints to all of them. This guide, part of the responsive component patterns collection within Mastering Container Queries & Responsive Layouts, shows how to build a nav that flips from a horizontal row to a stacked disclosure menu based on its own container width, with a CSS-only toggle and a realistic accessibility plan.


Approach rationale: container size over viewport

The decision that drives everything is to stop asking "how wide is the screen?" and start asking "how wide is the space this nav was handed?". A container query answers the second question. By placing the nav inside a wrapper with container-type: inline-size, the @container rule evaluates against that wrapper's resolved inline size, so a 360px sidebar collapses the menu even on a 4K display.

CSS alone can also handle the open/closed state. A hidden checkbox plus the :checked pseudo-class, or the newer :has() selector, toggles a panel with zero JavaScript. The honest tradeoff: a pure-CSS toggle is keyboard operable but cannot announce aria-expanded to assistive technology, and a checkbox is semantically a form control, not a menu button. The right production posture is progressive enhancement — ship the CSS toggle as a working no-JS baseline, then layer a real <button> and a few lines of script on top to manage ARIA state. Everything below treats the CSS solution as that baseline, not the finish line.


Complete working implementation

This single block is self-contained: paste it into an HTML file and resize the wrapper to watch the nav reflow. The container query handles layout; the checkbox handles disclosure when stacked.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<style>
  /* The wrapper is the query container. The nav queries THIS element's width. */
  .nav-shell {
    container-type: inline-size;
    container-name: nav;
    /* Resize this to test: a real header would be 100%, a sidebar ~320px. */
    max-width: 100%;
    border: 1px solid #ccc;
    border-radius: 8px;
  }

  .nav {
    display: flex;
    align-items: center;
    gap: 1rem;
    padding: 0.75rem 1rem;
  }

  .nav__brand { font-weight: 700; margin-right: auto; }

  /* The disclosure checkbox and its label are visually hidden by default,
     because the wide layout shows links inline and needs no toggle. */
  .nav__toggle { position: absolute; opacity: 0; pointer-events: none; }
  .nav__toggle-label {
    display: none;
    cursor: pointer;
    padding: 0.4rem 0.6rem;
    border: 1px solid currentColor;
    border-radius: 6px;
    font: inherit;
  }

  .nav__links {
    display: flex;
    gap: 1rem;
    list-style: none;
    margin: 0;
    padding: 0;
  }
  .nav__links a { text-decoration: none; padding: 0.25rem 0.4rem; }

  /* WIDE state is the default (mobile-up base). When the CONTAINER is narrow,
     switch to a stacked, toggle-driven menu. We query max-width on the
     container, not the viewport. */
  @container nav (max-width: 480px) {
    .nav__toggle-label { display: inline-block; }

    /* Collapse the link list into a vertical panel that is hidden until open. */
    .nav__links {
      flex-direction: column;
      gap: 0;
      width: 100%;
      flex-basis: 100%;
      /* Animate from height 0; overflow clip prevents partial reveal. */
      display: grid;
      grid-template-rows: 0fr;
      overflow: hidden;
      transition: grid-template-rows 0.25s ease;
    }
    .nav__links > li { min-height: 0; }
    .nav__links a { display: block; padding: 0.6rem 0.4rem; }

    /* :has() reads the checkbox state on the ancestor so the panel can open.
       This works because :has() lets a parent style react to a descendant. */
    .nav:has(.nav__toggle:checked) .nav__links {
      grid-template-rows: 1fr;
    }
  }

  /* Fallback for engines without :has(): use the adjacent-sibling combinator.
     The checkbox must precede the list in source order for ~ to reach it. */
  @container nav (max-width: 480px) {
    .nav__toggle:checked ~ .nav__links { grid-template-rows: 1fr; }
  }

  /* Reduced-motion users get an instant open with no height tween. */
  @media (prefers-reduced-motion: reduce) {
    .nav__links { transition: none; }
  }
</style>
</head>
<body>
  <div class="nav-shell">
    <nav class="nav" aria-label="Primary">
      <span class="nav__brand">Acme</span>
      <!-- Checkbox precedes the list so the ~ fallback can match. -->
      <input class="nav__toggle" type="checkbox" id="nav-toggle">
      <label class="nav__toggle-label" for="nav-toggle">Menu</label>
      <ul class="nav__links">
        <li><a href="#">Products</a></li>
        <li><a href="#">Pricing</a></li>
        <li><a href="#">Docs</a></li>
        <li><a href="#">Contact</a></li>
      </ul>
    </nav>
  </div>
</body>
</html>

The diagram below shows the two end states the single rule produces.

Nav reflow by container width Left: wide container with brand and inline links. Right: narrow container with brand, menu toggle, and stacked links. Wide container Narrow container Acme @container nav (min-width) Acme Menu :has(:checked) opens

Key technique callout: :has() reading a sibling's state

The line that makes the disclosure work without script is .nav:has(.nav__toggle:checked) .nav__links. Historically CSS could only style downward, so a checkbox could influence later siblings via ~ but never an ancestor or arbitrary cousins. :has() removes that limitation: it lets the .nav element match conditionally on the presence of a checked descendant, so styling the link panel no longer depends on its source position relative to the input. The grid trick — animating grid-template-rows from 0fr to 1fr — gives a smooth height transition that max-height hacks cannot, because there is no magic-number ceiling to guess.


Variation: a focus-visible accessible toggle

The CSS baseline is keyboard operable, but the label has no visible focus indicator on many engines because the real focus lands on the hidden checkbox. Forward the focus ring to the label and respect motion preferences with this addition. For the broader treatment, see the guide on creating accessible focus indicators.

/* Surface the hidden checkbox's focus state on its visible label. */
.nav__toggle:focus-visible + .nav__toggle-label {
  outline: 2px solid #7aa2ff;
  outline-offset: 2px;
}

/* Larger hit target and clearer affordance in the narrow state. */
@container nav (max-width: 480px) {
  .nav__toggle-label { min-height: 44px; display: inline-flex; align-items: center; }
}

When you progressively enhance with JavaScript, replace the label/checkbox with a <button aria-expanded="false" aria-controls="nav-links"> and toggle both the attribute and a class. Keep the container query untouched; only the disclosure mechanism upgrades.


Browser support note

Size container queries are Baseline and have shipped since Chrome and Edge 105, Safari 16, and Firefox 110, so @container is safe in 2026. The :has() selector arrived later — Chrome and Edge 105, Safari 15.4, Firefox 121 — but is now widely available; the ~ sibling rule in the code is a complete fallback for the disclosure where :has() is absent. If you must support pre-2023 engines, wrap the whole component in @supports (container-type: inline-size) and serve a @media-based collapse as the legacy path.


FAQ

Why use @container instead of media queries for navigation? A container query reacts to the nav's own available width, so the same component collapses correctly whether it sits in a wide header, a narrow sidebar, or a card. Media queries only know the viewport, which breaks reusable components placed in unpredictable contexts.

Can a CSS-only disclosure menu be accessible? Partly. A hidden checkbox toggle is keyboard operable and works without JavaScript, but it cannot expose aria-expanded state to assistive technology. For production, progressively enhance with a real button plus a small script while keeping the CSS toggle as the no-JS baseline.

Does :has() have good enough browser support in 2026? Yes. :has() shipped in Chrome and Edge 105, Safari 15.4, and Firefox 121, so it is broadly available. Provide a checkbox sibling-selector fallback for the rare engine that lacks it.

Why does my container query never match on the nav? The queried ancestor needs container-type. Set container-type: inline-size on the nav's wrapper, because an element cannot query its own size — only that of a containing ancestor.


Related articles

More pages in the same section.