CSS is Awesome

Mobile — the playbook

cia ships two mobile layouts, and both are pure CSS — CSS Grid for structure, the native Popover API for interaction, zero JavaScript. The app layout is for tool-like pages: a fixed bottom dock in thumb reach whose slots open slide-up sheets — the cia docs site itself runs this on phones. The flex layout is one fluid page shell that moves with the shape and size of the screen: Nav / Main / Footer as named grid areas, with a hamburger and drawer below the breakpoint. Pick one per page; this playbook covers both, plus the doctrine and lessons behind them.

The doctrine: Grid is the skeleton, Flex is the quick moves

Three levels, strictly:

  1. The page shell is CSS Grid with landmark-named areas — the body's areas are the document's landmarks (nav, main, footer), so the area map reads like the page and screen readers get the structure for free.
  2. The doctrine scales inward — any control-dense region (a docs article, a selections rail, a dashboard) gets its own named-area grid, and that grid's gap is the region's entire vertical rhythm. No child carries rhythm margins.
  3. Flex lives at the leaves — simple rows you flip with one command (@include cia.flex($direction: column)). Flex arranges the contents of a slot the grid gave it; it never builds the page.

And the mobile consequence: mobile is a different area map, not margin overrides. You declare where every region sits at each width — the browser does the rest. cia.page-layout() gives you the full-viewport shell with one include:

@use 'css-is-awesome/api' as cia;

// Preset shells: default | sidebar-left | sidebar-right | holy-grail
body            { @include cia.page-layout(sidebar-left); }
body > header   { @include cia.page-header; }
body > aside    { @include cia.page-sidebar; }
body > main     { @include cia.page-main; }
body > footer   { @include cia.page-footer; }

Every preset is a viewport-height grid (100dvh, sticky footer for free) and multi-column presets auto-flatten to a single column below the collapse breakpoint (md by default; pass $collapse-at to change it). On a phone, sidebar-left becomes header → sidebar → main → footer stacked — no extra code.

When the presets don't fit, build the map from your own region names with cia.layout() and place children with cia.area():

// A docs page: sidebar | content | toc on desktop
.docs-shell {
  @include cia.layout((sidebar content toc), $tracks: 16rem 1fr 12rem);

  // A different area map on mobile — content first, toc gone
  @include cia.media-down(md) {
    grid-template-columns: 1fr;
    grid-template-areas: "content" "sidebar";
  }
}

.docs-sidebar { @include cia.area(sidebar); }
.docs-content { @include cia.area(content); }
.docs-toc     { @include cia.area(toc); }

Two facts about area maps that save you an afternoon of debugging:

  1. Omitting an area from the mobile map does NOT hide the element. A grid child whose named area doesn't exist in the current template is auto-placed — it still renders, somewhere you didn't plan. Pair the omission with display: none:
    .docs-toc {
      @include cia.area(toc);
      @include cia.mobile-only { display: none; }  // omitted from the map ≠ hidden
    }
  2. Multi-column presets auto-flatten. You only write a custom mobile map when the stacking order should differ from the desktop reading order (or a region should disappear). Otherwise cia.page-layout() already collapsed it for you.

App layout — dock + sheets

For tool-like pages — dashboards, editors, the docs site itself — a top nav is the worst place for navigation on a phone: it's the one region a thumb can't reach. The app layout moves navigation to a fixed bottom dock whose slots open slide-up sheets. The dock is a CSS grid with equal tracks per slot; the sheets are Popover-API panels, so the browser manages open/close state, Esc, light-dismiss and aria-expanded — zero JavaScript.

<nav class="quick-dock" aria-label="Quick menu">
  <button popovertarget="docs-sheet">Docs</button>
  <button popovertarget="themes-sheet">Themes</button>
  <a href="/playground" aria-current="page">Playground</a>
</nav>

<section id="docs-sheet" class="quick-sheet" popover aria-label="Docs">
  …links…
</section>
@use 'css-is-awesome/api' as cia;

.quick-dock {
  @include cia.dock(3);              // 3 equal grid tracks, fixed to the bottom,
                                     // border on top, safe-area padding below
  > button,
  > a { @include cia.dock-item; }    // 56px targets; lights up on
                                     // [aria-expanded="true"] and [aria-current]
}

.quick-sheet {
  @include cia.sheet;                // bottom drawer preset: slides up, caps at
                                     // 72dvh, rounded shoulders, safe-area padding
}

Note what's absent: no .open class, no click handler. The browser sets aria-expanded on any popovertarget invoker, and dock-item styles the active state off that attribute (and off aria-current for routed slots) — semantic state lives in ARIA, never a class. cia.sheet($max: 72dvh) caps the panel so the page stays visible behind it; dvh survives mobile URL-bar resizing.

The full pattern — markup, sheet contents, focus notes — is the bottom-nav recipe.

Flex layout — shell + hamburger + drawer

For content pages — marketing, blogs, docs prose — you want one fluid shell that moves with the shape and size of the screen. The flex layout is cia.page-layout() from the doctrine section plus a navigation swap below the breakpoint: inline links on desktop, a hamburger button opening a drawer on mobile. Same zero-JS mechanics — the drawer is a popover, the hamburger morphs to an X off the browser-managed [aria-expanded].

<header>
  <a href="/">Acme</a>
  <nav class="site-links" aria-label="Site">…inline links…</nav>
  <button class="menu-button" popovertarget="site-menu" aria-label="Menu">
    <span></span><span></span><span></span>
  </button>
</header>

<nav id="site-menu" class="site-drawer" popover aria-label="Site menu">
  …the same links, stacked…
</nav>
@use 'css-is-awesome/api' as cia;

body          { @include cia.page-layout; }     // header / main / footer shell
body > header { @include cia.page-header; }
body > main   { @include cia.page-main; }
body > footer { @include cia.page-footer; }

// Inline links on desktop, hamburger below md — swap, don't duplicate
.site-links  { @include cia.mobile-only { display: none; } }
.menu-button {
  @include cia.hamburger;            // three bars; morphs to X on
                                     // [aria-expanded="true"] — browser-managed
  @include cia.tablet { display: none; }
}

.site-drawer {
  @include cia.drawer($side: start); // slides in from the reading-direction
                                     // start edge; Esc + light-dismiss free
}

cia.drawer() takes logical sides — start | end | top | bottom — so an RTL locale mirrors the drawer with no extra code. $size sets the panel width (or height for top/bottom). If you can't use a popover (a :checked checkbox fallback, say), the morph is available alone as cia.hamburger-open:

.menu-check:checked + .menu-button { @include cia.hamburger-open; }

The full pattern is the mobile-nav recipe.

The house rule for every interactive surface on a phone: it fills the container it lives in — 100% of its column, inside the page's existing padding and formatting. Never edge-to-edge past the gutters, never a floating mid-width popup. For a dropdown that means the trigger stretches to 100% with justify-content: space-between (label left, affordance right), and the menu opens 1px under the trigger, at the trigger's exact width — flipping above it when it would run off the bottom of the screen.

One wrinkle: a popover menu lives in the top layer, so it can't size to its container directly — width: 100% means nothing up there. CSS anchor positioning closes the gap: name the trigger an anchor, pin the menu's inline edges to it, and keep viewport-minus-gutters as the fallback for engines without anchors:

@use 'css-is-awesome/api' as cia;

.nav-trigger {
  @include cia.mobile-only {
    display: flex;
    inline-size: 100%;               // the space it's in: the whole column
    justify-content: space-between;  // label left, affordance right
    anchor-name: --nav-trigger;
  }
}

.nav-menu {
  @include cia.mobile-only {
    @include cia.dropdown;           // zero-JS popover menu

    // Must match the mixin's &[popover] specificity — its inset reset
    // wins over a bare class otherwise.
    &[popover] {
      inset-inline: cia.space(4);    // no-anchor fallback: viewport minus gutters
      min-width: 0;
      width: auto;                   // the UA's [popover] { width: fit-content }
                                     // otherwise beats both inline edges

      @supports (anchor-name: --a) {
        position-anchor: --nav-trigger;
        inset-inline: anchor(start) anchor(end);     // the trigger's width
        inset-block-start: calc(anchor(end) + 1px);  // 1px below the trigger
        position-try-fallbacks: flip-block;          // flip above at screen bottom
      }

      a { white-space: normal; overflow-wrap: anywhere; }  // long labels wrap
    }

    a { @include cia.dropdown-item; }
  }
}

Two of those lines are load-bearing in ways that aren't obvious. The &[popover] nesting isn't style — cia.dropdown resets the UA popover defaults (inset: unset) at that specificity, so a bare class loses to it. And width: auto looks redundant but isn't: the UA stylesheet says [popover] { width: fit-content }, which beats both inline insets — without the override the menu hugs its content instead of matching the trigger. cia.dropdown also guards its own closed state (the menu's display: flex is re-asserted only under :popover-open), so it never renders permanently open on popover markup.

Inside the menu, long labels wrap — never clip, never scroll sideways. The one deliberate exception to the take-the-space rule is code: on phones a code block may run off into a horizontal scroll inside its own box (white-space: pre; overflow-x: auto) — never wrapped or shrunk to fit, and never widening the page; the copy button carries usability for long lines. And a consumer-level JS nicety is allowed: real links close the popover by navigating, so if yours don't (soft navigation, demo links), one delegated click handler calling hidePopover() is all it takes. The Dashboard shell demo in the recipes gallery is the reference implementation of the whole pattern.

Breakpoint helpers

Both layouts key off the same scale: sm 640px, md 768px, lg 1024px. The helpers are content blocks:

.site-links {
  @include cia.media-down(md) { display: none; }  // below a breakpoint
}
.toc         { @include cia.mobile-only { display: none; } }  // < md
.side-panel  { @include cia.tablet  { /* ≥ md */ } }
.mega-menu   { @include cia.desktop { /* ≥ lg */ } }

cia is direction-agnostic: media-down() for desktop-first authoring, tablet / desktop for mobile-first — both are one line. The real responsive work happens at the component level, with the area maps above.

Mobile lessons

Five things that separate a page that works on a phone from a page that merely fits on one.

Tap targets: 44px minimum

Fingers are not cursors. cia's interactive mixins already honor --touch-target-min (44px — cia.hamburger defaults to it; cia.dock-item gives 56px). For your own controls, enforce it the same way:

.icon-action {
  min-width: var(--touch-target-min, 44px);
  min-height: var(--touch-target-min, 44px);
}

Clear the dock

A fixed bottom dock floats over the page — the last paragraph of scrollable content will hide behind it unless the scroll container reserves room. Pad past the dock and the home-indicator safe area:

main {
  @include cia.mobile-only {
    padding-block-end: calc(6rem + env(safe-area-inset-bottom));
  }
}

Wide tables restack into cards

A six-column table cannot shrink to 375px; it can only overflow or lie. Below the breakpoint, restack each row into a labeled card: the cells go block-level, and each cell prints its own column header from a data-label attribute in the markup (<td data-label="Price">$24</td>):

.plans-table {
  @include cia.mobile-only {
    thead { display: none; }        // headers move into the cells
    tr {
      display: block;
      border: 1px solid cia.color(border-default);
      border-radius: cia.radius(md);
      margin-block-end: cia.space(4);
    }
    td {
      display: flex;
      justify-content: space-between;
      padding: cia.space(2) cia.space(4);

      &::before {                   // the label rides in from the markup
        content: attr(data-label);
        font-weight: cia.font-weight(medium);
      }
    }
  }
}

Icon-only controls keep their name

Narrow widths tempt you to drop button text and keep the icon. Fine — but the accessible name must survive. An icon-only control without aria-label is announced as "button", full stop:

<!-- text hidden below md; the name stays -->
<button class="search-button" aria-label="Search">
  <svg aria-hidden="true">…</svg>
  <span class="search-label">Search</span>
</button>
.search-label { @include cia.mobile-only { display: none; } }

The dock example above follows the same rule: slots that show only an icon at narrow widths carry aria-label.

The spacing-scale trap

Mobile spacing tweaks mean lots of cia.space() calls — so know the scale. It's numbered 19 and nonlinear: 5 is 24px, 7 is 48px, 8 is 64px. Two consequences: don't guess that 8 means 32px (that's 6), and don't pass a key that isn't on the scale — unknown keys pass through raw and silently invalidate the declaration:

padding-block-end: cia.space(7);   // 48px — on the scale ✓
padding-block-end: cia.space(12);  // emits `padding-block-end: 12` —
                                   // invalid CSS, dropped by the browser,
                                   // no compile error to warn you

Which layout for which page?

Either way the doctrine holds: stack by named grid areas, style state off ARIA, and ship no JavaScript for any of it.

Theme