tanghoong design
  1. Design system
  2. Layout

03 — Layout

Layout

Containers, section banding, the sticky header, the footer, grids and sidebars. Layout owns all vertical rhythm so components never have to.

Container

One class, four widths. The gutter is fluid — clamp(20px, 5vw, 40px) — so it never crowds on a phone or float unanchored on a wide desktop.

Widths
--container-prose · 720px
--container-mid · 940px
--container · 1120px (default)
--container-wide · 1320px
<div class="container">…</div>              <!-- 1120px, the default -->
<div class="container container--prose">…  <!-- 720px, article bodies -->
<div class="container container--mid">…    <!-- 940px -->
<div class="container container--wide">…   <!-- 1320px, dense dashboards -->

Prose is 720 px because that is roughly 68 characters at 17 px — the comfortable reading measure. Do not run body copy at container width.

Sections and banding

A page is a stack of <section class="section">. The section owns its vertical padding via --section-y — a fluid clamp(72px, 9vw, 128px). Alternate .s-page and .s-sunken to band the scroll so the eye can find where one idea ends.

Banding
.section .s-page

Hero, primary content.

.section .s-sunken

A supporting band. Recedes, so what sits on it lifts.

.section .s-page

Back to the page ground.

<section class="section s-page">
  <div class="container">
    <div class="sec-head">
      <div>
        <h2>Selected work</h2>
        <p class="sec-head__intro">Five projects that shipped.</p>
      </div>
      <a class="link-more" href="/work">All work</a>
    </div>
    …
  </div>
</section>

.section--tight halves the padding for a dense stack of sections. .section--flush removes it when the child supplies its own.

Section header

Selected work

Five projects, each with the constraint that made it hard.

All work

Title on the left, an optional action on the right, aligned to the baseline and wrapping to two rows on a narrow screen.

Sticky, translucent, and it blurs what scrolls beneath it. The @supports guard keeps a solid fallback where backdrop-filter is unavailable — without it those browsers get unreadable text over content.

Structure — the header at the top of this page
<header class="site-head">
  <div class="container head-in">
    <a class="brand" href="/">
      <svg class="brand__mark" aria-hidden="true">…</svg>
      <span>Site name</span>
    </a>

    <nav class="head-nav" aria-label="Primary">
      <a href="/work">Work</a>
      <a href="/about">About</a>
    </nav>

    <!-- Two controls, always side by side. Nothing else belongs here. -->
    <div class="head-end">
      <button class="iconbtn" data-theme-toggle aria-label="Theme">…</button>
      <button class="iconbtn navsheet__trigger" data-navsheet-open
              aria-label="Menu" aria-haspopup="dialog">…</button>
    </div>
  </div>
</header>

<!-- 🔴 The sheet is a SIBLING of the header, never a child. The header sets
     backdrop-filter, which traps position:fixed descendants inside it. -->
<dialog class="drawer navsheet" data-navsheet aria-label="Site menu">…</dialog>

The breakpoint is 900 px: below it .head-nav is display:none and the menu button appears; above it the nav returns and the button hides. The theme toggle is visible at every width. Keep the corner to these two — a call to action there competes with the one in the hero, and the corner loses. See Overlays for the sheet itself.

Anchors under a sticky header utilities.css sets scroll-margin-block-start on every element with an id. Without it, clicking an in-page link lands the target heading behind the header bar.

A three-column grid above a legal line: identity on the left in the wider track, then two link columns. Collapses to a single column below 640 px.

Structure — the footer at the bottom of this page
<footer class="site-foot">
  <div class="container">
    <div class="foot-grid">
      <div>
        <p class="foot-name">Site name</p>
        <p class="foot-role">One line on what this is.</p>
      </div>
      <div>
        <p class="foot-h">Sitemap</p>
        <ul class="foot-list"><li><a href="/work">Work</a></li></ul>
      </div>
      <div>
        <p class="foot-h">Elsewhere</p>
        <ul class="foot-list"><li><a href="…">GitHub</a></li></ul>
      </div>
    </div>
    <p class="foot-legal">Copyright line.</p>
  </div>
</footer>

Footer links are 40 px tall rather than 44 — they are a dense list, not a primary target, and the extra height would push the footer too long.

Grids

Mobile-first: one column, and the breakpoint adds tracks. Every track is minmax(0, 1fr), not 1fr — that is what stops a long unbroken string from blowing the track out and forcing the page to scroll sideways.

.grid--2, --3, --4
1
2
3
1
2
3
4

Both --3 and --4 go to two columns at 720 px before reaching full width at 960 px. Three columns on a tablet is too narrow to read.

Content and aside

The two-column reading layout used on this page. The rail sticks below the header once the article scrolls past it — and only from 960 px up, where there is room for it. Below that it stacks above the content.

Markup
<div class="with-aside">
  <div>
    <h2 id="one">Section one</h2>
    …
  </div>

  <aside class="rail">
    <p class="rail__h">On this page</p>
    <ul class="rail__list">
      <li><a href="#one">Section one</a></li>
    </ul>
  </aside>
</div>

.with-aside--start puts the rail first, for docs navigation. The rail is align-self:start so it does not stretch to the content height — position:sticky would do nothing if it did.

Cluster and stack

Two primitives that cover most of the remaining spacing needs. .cluster is a wrapping horizontal row; .stack is the owl selector as a class.

Cluster
Live Tag
<div class="cluster">…</div>   <!-- flex, wrap, gap --s-3, centre-aligned -->
<div class="stack">…</div>     <!-- every child after the first gets --s-4 above -->
<div class="stack stack--lg">…  <!-- --s-8 instead -->

Breakpoints

Four, all written as modern range syntax (@media (width >= 720px)). They are content breakpoints, not device widths — each one is where a particular layout stops working, which is why they are not round numbers from a device chart.

WidthWhat changes
640pxFooter goes to three columns; .facts becomes two columns; form grids split
720pxCard and grid layouts go to two columns; .row puts meta on the right
900pxHeader nav, tools and CTA appear; the mobile sheet disappears
960pxGrids reach three and four columns; the aside rail becomes sticky
Do Add a breakpoint where the content stops working. Test by resizing until something looks wrong, and put it there.
Don't Add a fifth breakpoint for one component. Reach for clamp(), minmax() or auto-fill first — most cases do not need a media query at all.