Create a two-column layout with a fixed sidebar

The problem

A documentation page pairs a persistent sidebar with a scrolling content column. The sidebar keeps one fixed design width, the content column must absorb every remaining pixel, and long unbreakable strings inside it must not push the page sideways. The same markup should stack into one column on narrow viewports without a wrapper rewrite.

Short answer

Declare grid-template-columns: 16rem minmax(0, 1fr) on the container, separate the columns with gap, then override the track list with a single minmax(0, 1fr) track in a max-width media query.

A page shell that pairs a persistent sidebar with a fluid content column is one grid track list, not a float hack or a percentage width on both children. Naming the tracks lets the sidebar hold an exact measure while the content column takes the remainder, and the same two rules can be reduced to one track when the viewport runs out of room.

Language: HTML
<div class="site-layout">
  <aside class="site-sidebar">
    <h2>On this page</h2>
    <ul>
      <li><a href="#track-list">Track list</a></li>
      <li><a href="#narrow-widths">Narrow widths</a></li>
      <li><a href="#sticky-behaviour">Sticky behaviour</a></li>
    </ul>
  </aside>
  <main class="site-content">
    <h1>Two columns with CSS grid</h1>
    <p>This column receives every pixel the sidebar does not claim.</p>
  </main>
</div>

Language: CSS
.site-layout {
  display: grid;
  grid-template-columns: 16rem minmax(0, 1fr);
  align-items: start;
  gap: 2rem;
  max-inline-size: 76rem;
  margin-inline: auto;
  padding: 1.5rem;
}

.site-sidebar {
  position: sticky;
  inset-block-start: 1.5rem;
  max-block-size: calc(100dvh - 3rem);
  overflow: auto;
  padding-inline-end: 1rem;
  border-inline-end: 1px solid var(--de-border, #c7ccd4);
  overflow-wrap: break-word;
}

@media (max-width: 47.9375rem) {
  .site-layout {
    grid-template-columns: minmax(0, 1fr);
  }

  .site-sidebar {
    position: static;
    max-block-size: none;
    padding-inline-end: 0;
    border-inline-end: 0;
  }
}

Explanation

The container declares exactly two tracks, so the split is a property of the parent rather than a width claimed by each child. The first track is a hard 16rem: it never yields space, and it scales with the reader’s root font size because the unit is rem, which is what you want from a design token that also has to work at 200 percent zoom. The second track is minmax(0, 1fr) rather than a bare 1fr, and that zero is the whole reason the layout behaves. A single fr is shorthand for minmax(auto, 1fr), and auto as a minimum resolves to the item’s automatic minimum size, so one long unbreakable string in a code block or a URL sets a floor under the track and the grid becomes wider than its container. With the minimum pinned to zero, the track can never exceed the space left after the sidebar and the gap, and overflow stays inside the column where overflow-wrap: break-word can act on it.

The remaining declarations are choices about who owns each piece of space. gap carries the column separation so neither region needs a margin, which also means the stacked case needs no margin reset for the gap. align-items: start makes each item take its content height instead of stretching to the row; that is what allows the sidebar to be sticky, because a stretched item is already as tall as the row it travels in and has no room left to move. max-inline-size with margin-inline: auto caps line length on wide displays. Logical properties (max-inline-size, margin-inline, padding-inline-end, inset-block-start) keep the same rules correct for right-to-left content without a second block of mirrored values. The 100dvh cap plus overflow: auto on the sidebar bounds its own height against the dynamic viewport, so a long table of contents scrolls inside its box instead of stretching the row.

Two boundary conditions are worth knowing before this ships. First, the breakpoint comes from arithmetic rather than taste: the 16rem track, the 2rem gap and 3rem of horizontal padding mean that at 48rem of viewport the content column is about 27rem wide, and below that prose lines get short enough to hurt scanning, so the single-track override fires just before the 48rem mark. Second, when the override applies, both regions become full-width rows in source order, which means the sidebar’s links are read and tabbed through before the main content. Fixing that with order or with grid placement that moves a region away from its source position only creates a mismatch between visual and reading order, so the safe options are to accept the nav-first stack or to hide the sidebar behind a disclosure control at that width.

Usage notes

  • Keep the sidebar width in exactly one declaration; repeating it as a max-inline-size on the aside gives you two numbers to change.
  • If the sidebar may be narrower than 16rem, use minmax(min-content, 16rem) and drop the media query instead of adding a second breakpoint.
  • The sticky sidebar needs align-items: start on the container; a stretched grid item is already row-height and has nothing left to travel.

Caveats

  • The breakpoint is written in rem, so it moves with the user's root font size as well as with the viewport: a large base font reaches the stacked layout on a wide screen.
  • A sticky sidebar silently changes behaviour if any ancestor between it and the page scrollport sets overflow; see the sticky table header Example for that rule.

Alternatives

Grid names the two tracks you actually mean, so a hard 16rem column and a fluid column survive long unbreakable content with one extra argument. Flexbox needs a flex-basis, a shrink factor and overflow handling to describe the same split, which makes it the better tool for centring one item rather than for a measured two-column frame.

Related examples

Editorial links first, then deterministic same-task or same-topic candidates. Tags and shared language alone never qualify a candidate.

References