Container Query Sidebar Layouts That Travel Between Contexts

A sidebar-and-main pair is the layout most likely to be built twice. The first version lives on a full-width page and switches to a single column at a viewport breakpoint. Then someone drops the same component into a modal, or into the right-hand third of a dashboard, and the viewport is still 1440px wide while the component has 320px to work with — so the two-column rule fires and the sidebar squeezes the main column into a ribbon. This page builds the pair once, as a self-measuring component, following the patterns in responsive component patterns and the wider approach set out in Mastering Container Queries & Responsive Layouts. The hard part is not the two-column rule; it is collapsing the sidebar into a horizontal strip without disturbing the order a keyboard or screen reader user moves through.

Why the container, not the viewport

The viewport is a property of the window. The available inline size is a property of the slot the component was placed in. Those two numbers agree only in the special case of a full-bleed page section, and a component library exists precisely because components get placed in slots you did not anticipate. A media query encodes an assumption about the page; a @container rule encodes a fact about the box.

There is a second, quieter reason. A viewport breakpoint forces every instance on the page to switch at the same moment. If a dashboard shows three of these panels at different widths, that is wrong for at least two of them. Container queries let each instance answer independently, which is the same reasoning behind container query data tables collapsing columns per table rather than per page.

The tradeoff is real and worth naming. The queried element cannot be the container — an element cannot query its own size, because the size it would report is the size the query is about to change. So you pay one wrapper element per component. That wrapper also needs a resolvable inline size from its own parent; a container inside a float or an unconstrained absolutely positioned box may measure zero and quietly never match anything.

One component, two arrangements, one DOM order In a narrow container the sidebar sits above the main region as a horizontal strip; in a wide container it becomes a left-hand column. The markup is identical in both. threshold: 34rem of container width narrow container sidebar strip main region wide container sidebar main Sidebar precedes main in the DOM in both states.

The complete component

Drag the frame narrower and watch two separate thresholds fire: the two-column grid dissolves first, then the facet chips stop sitting side by side.

Live demoSidebar layout that reorganises on its own container width
Drag the handle on the right edge. Below 34rem the sidebar becomes a horizontal strip above the article; below 22rem the facets wrap to their own lines. Drag the bottom-right corner to resize the frame in either direction.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Container-driven sidebar</title>
<style>
  body { margin: 0; padding: 1rem; font: 16px/1.5 system-ui, sans-serif; }

  /* The wrapper is the container. The grid lives on it too, which is fine:
     container-type: inline-size only contains size and layout, not children's
     participation in the wrapper's own formatting context. */
  .pane { container: pane / inline-size; }

  .pane__side,
  .pane__main {
    border: 1px solid #d4d4d8;
    border-radius: 10px;
    padding: 0.85rem 1rem;
  }

  /* Single-column base. Every browser gets this, including ones that never
     evaluate the @container rules below. */
  .pane__side { margin-block-end: 0.75rem; }

  .side__title,
  .main__title {
    margin: 0 0 0.5rem;
    font-size: 0.8rem;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    opacity: 0.7;
  }

  .side__facets {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-wrap: wrap;
    gap: 0.4rem;
  }

  .facet {
    border: 1px solid #d4d4d8;
    border-radius: 999px;
    padding: 0.25rem 0.7rem;
    font-size: 0.85rem;
  }

  .main__body { margin: 0; }

  /* Very narrow: a wrapping row of chips reads as noise, so give each its
     own line. This threshold is about the strip's contents, not the pair. */
  @container pane (max-width: 22rem) {
    .side__facets { flex-direction: column; align-items: stretch; }
    .facet { text-align: center; }
  }

  /* Enough room for two columns. minmax(0, 1fr) on the main track stops a
     long unbreakable string from pushing the column past the container —
     the default minimum of a grid track is auto, i.e. min-content. */
  @container pane (min-width: 34rem) {
    .pane {
      display: grid;
      grid-template-columns: 14rem minmax(0, 1fr);
      gap: 0.75rem;
      align-items: start;
    }
    .pane__side { margin-block-end: 0; }
    .side__facets { flex-direction: column; align-items: stretch; }
  }
</style>
</head>
<body>
  <div class="pane">
    <aside class="pane__side" aria-labelledby="facets-title">
      <h2 class="side__title" id="facets-title">Filters</h2>
      <ul class="side__facets">
        <li class="facet">In stock</li>
        <li class="facet">Under $50</li>
        <li class="facet">Free returns</li>
      </ul>
    </aside>
    <article class="pane__main" aria-labelledby="results-title">
      <h2 class="main__title" id="results-title">Results</h2>
      <p class="main__body">The sidebar never asks how wide the window is. It asks
      how wide this pane is, so the same markup works full-bleed, in a modal, or
      in a narrow column.</p>
    </article>
  </div>
</body>
</html>

Note what is not in that stylesheet: no order, no grid-row, no grid-area names, no absolute positioning. The stacked state and the two-column state both follow document order, so there is nothing to keep in sync.


Key technique: two thresholds, one axis

The component has two independent decisions to make, and they are not the same decision. "Can the sidebar and main sit beside each other?" is a question about the pair — it depends on the sum of a usable sidebar and a usable main column, which is why 34rem and not some round viewport-flavoured number. "Can the facet chips share a line?" is a question about the sidebar's own content. Writing them as separate @container rules with separate thresholds means each one changes when its own content demands it.

Both rules query inline size only. container-type: inline-size applies containment on the inline axis and leaves block size to flow naturally, so the panel can grow as tall as its content without the query re-evaluating. The alternative, container-type: size, requires you to supply a block size from outside, and a text panel has no business having one; the distinction is worked through in container-type: size vs inline-size.

The shorthand container: pane / inline-size sets container-name and container-type in one declaration. Naming is optional for a single container, but it becomes load-bearing the moment one of these panes ends up inside another — an unnamed @container rule binds to the nearest ancestor container, which after nesting may not be the one you meant.


Source order versus visual order

The tempting shortcut, once the strip exists, is to put the sidebar last in the HTML because "on mobile the filters should come after the results", then use order: -1 or grid-row: 1 to hoist it back above the main region on wide containers. This works visually and is a genuine accessibility defect.

order and grid placement are visual-only. They change where boxes paint; they do not change the accessibility tree, and they do not change sequential focus navigation, which follows DOM order. So a sighted keyboard user Tabs from a control at the top of the screen straight into a control near the bottom, then back up. CSS Flexbox and CSS Grid both say as much in their layout specifications, and WCAG 2.2 Success Criterion 1.3.2 Meaningful Sequence and 2.4.3 Focus Order are the criteria you fail.

The rule that keeps you out of trouble: pick the DOM order you want read aloud, then only ever change the axis, not the sequence. Going from a column to a row keeps the first child first — it just moves it from "above" to "left of". Going from a column to a reversed row does not. If you genuinely need filters after results in the reading order and above them visually, that is a signal to change the markup — for example by making the sidebar a disclosure that sits early in the DOM and is collapsed by default in narrow containers, a pattern that composes well with CSS-only accordions and disclosure.

One legitimate exception: purely decorative or duplicated content, which is not in the reading order to begin with. Everything else, fix in the HTML.


Variation: a sticky sidebar that knows when to give up

On a wide container the sidebar is short and the main region is long, so position: sticky earns its place. On a narrow container it is a horizontal strip at the top of a scrolling region, where sticking it would eat the viewport. Because the sticky rule lives inside the same @container block, it turns itself off automatically:

@container pane (min-width: 34rem) {
  .pane__side {
    position: sticky;
    top: 1rem;
    /* Never taller than the viewport, so a long facet list scrolls
       inside the sidebar rather than escaping it. */
    max-block-size: calc(100dvh - 2rem);
    overflow-y: auto;
    /* Keyboard users can scroll a focusable scroll container; without a
       tabindex some engines will not let them reach it at all. */
    scrollbar-gutter: stable;
  }
}

100dvh tracks the dynamic viewport, so the sidebar does not sit partly under a mobile browser's retracting toolbar. If you support engines without dvh, precede it with a 100vh declaration as a same-property fallback.


Browser support

Size container queries — container-type, the container shorthand and @container — shipped in Chrome 105, Edge 105, Safari 16.0 and Firefox 110, so they are safely assumed on current browsers. minmax() and grid-template-columns predate them by years and need no guard, and the dynamic viewport units (dvh) used here are supported in every current engine.

Because the base styles are the single-column arrangement, an engine that ignores @container entirely renders the stacked strip layout, which is correct rather than broken. If you need the two-column arrangement on such engines, add a viewport-based approximation guarded by @supports not (container-type: inline-size); the technique is covered in feature detection with @supports.


FAQ

Why does my sidebar layout break when I move it into a modal? Because the breakpoint is a media query reading the viewport, which does not change when the component moves into a narrower box. Query the component's own wrapper with container-type: inline-size instead, and the modal copy reflows on its own measurements.

Is it safe to reorder the sidebar and main content with the order property? Only if the visual result still matches a sensible reading order. order and grid-row move boxes visually but leave DOM order untouched, so screen reader and Tab order follow the source. Fix the source order rather than paper over it visually.

Should the sidebar use a fixed rem width or a fraction unit? Use a fixed rem or a minmax() track for the sidebar and minmax(0, 1fr) for the main column. A fraction unit on both makes the sidebar grow on very wide containers, and an unclamped 1fr main column can be forced wider than the container by long unbreakable content.

Can I put a container query breakpoint in a custom property? No. The width in an @container prelude is not substituted from var(), because at-rule preludes are evaluated before custom property substitution. Keep the threshold literal and store the matching track sizes in custom properties instead.

Related articles

More pages in the same section.