Skip to content

Responsive Layout: Sidebar, Mobile Patterns

Sidebar and mobile navigation with concrete breakpoints and focus management , when CSS grid/flex beat another layout component.

5 min read
Responsive Design
Sidebar
CSS Grid
Mobile Navigation
Accessibility

Accessible primitives give you dialogs and menus. They do not give you an app shell. Most product UI fails first on layout: a sidebar that collapses badly, a mobile nav that traps focus wrong, or a component library used as a substitute for CSS.

I want concrete breakpoints, honest collapse behavior, and focus management that matches how keyboards actually move.

Start with structure, not chrome

Before picking a Sidebar package, draw the regions:

text
┌────────────┬─────────────────────────┐ │ nav │ main │ │ (sidebar) │ (route outlet) │ └────────────┴─────────────────────────┘ ↓ < md ┌──────────────────────────────────────┐ │ top bar + menu button │ ├──────────────────────────────────────┤ │ main │ └──────────────────────────────────────┘ (+ off-canvas nav / bottom tabs)

If the structure is wrong, no amount of Sheet animation saves you.

Breakpoints I actually write down

I document product breakpoints in tokens, not magic numbers scattered in JSX.

css
/* styles/breakpoints.css, illustrative */ :root { --bp-md: 768px; --bp-lg: 1024px; --sidebar-width: 16rem; } /* Mobile-first shell */ .app-shell { display: grid; grid-template-columns: 1fr; min-height: 100dvh; } @media (min-width: 768px) { .app-shell { grid-template-columns: var(--sidebar-width) 1fr; } .app-sidebar { position: sticky; top: 0; height: 100dvh; overflow: auto; } .app-nav-toggle { display: none; } }

Decision rule: if the only reason you imported a layout component was to set width: 240px and display: flex, CSS already won.

Desktop sidebar, mobile drawer

On small screens the sidebar becomes a temporary layer. That means:

  1. Closed by default on mobile
  2. Toggle in the top bar with an accessible name
  3. Focus moves into the drawer when opened
  4. Escape / backdrop closes and returns focus to the toggle
  5. aria-expanded on the control reflects state
tsx
// features/shell/AppShell.tsx, illustrative import { useEffect, useId, useRef, useState } from 'react'; export function AppShell({ nav, children, }: { nav: React.ReactNode; children: React.ReactNode; }) { const [open, setOpen] = useState(false); const panelId = useId(); const toggleRef = useRef<HTMLButtonElement>(null); const closeRef = useRef<HTMLButtonElement>(null); useEffect(() => { if (!open) return; closeRef.current?.focus(); const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [open]); return ( <div className="app-shell"> <header className="app-topbar"> <button ref={toggleRef} type="button" className="app-nav-toggle" aria-expanded={open} aria-controls={panelId} onClick={() => setOpen((v) => !v)} > Menu </button> </header> <div className="app-backdrop" hidden={!open} onClick={() => setOpen(false)} /> <nav id={panelId} className="app-sidebar" data-open={open} aria-label="Primary" > <button ref={closeRef} type="button" className="app-nav-close" onClick={() => { setOpen(false); toggleRef.current?.focus(); }} > Close menu </button> {nav} </nav> <main className="app-main">{children}</main> </div> ); }

Prefer a battle-tested Dialog/Sheet primitive (accessible-libraries post) for the mobile layer if you need a full focus trap. Do not invent a half-trap with position: fixed and hope.

When bottom tabs beat a sidebar

Mobile products with three to five top-level destinations often want bottom tabs, not a hamburger that hides the IA.

Use bottom tabs when:

  • Destinations are peer-level and frequent
  • Users need one-thumb reach
  • You can label icons with visible text (icon-only tabs fail more often than teams admit)

Keep the desktop sidebar when destinations are numerous, nested, or admin-dense. Forcing one pattern on all breakpoints is how you get a desktop hamburger with twelve items.

CSS grid/flex vs component chrome

NeedPrefer
Two-column shell, sticky navCSS grid + sticky
Collapsing gaps / alignmentFlexbox
Modal mobile nav with focus trapAccessible primitive
Resizable panelsDedicated splitter (pointer + keyboard)
Marketing page sectionsCSS; skip app-shell components

Component chrome pays off for behavior (trap, restore, scroll lock). It does not justify a component for display: grid.

Failure modes

Only testing the desktop breakpoint. The bug report arrives as "nav is gone on my phone."

Focus left behind the backdrop. Keyboard users tab into main content while the drawer still looks open.

100vh on mobile browsers. Prefer 100dvh / flex min-height patterns so the URL chrome does not clip the last action.

Sidebar width in JS. If resize observers exist only to set a CSS variable you could have authored, delete the JS.

Body scroll locked forever. Mobile drawers that set overflow: hidden on document.body and forget to clear it on route change leave the app unscrollable. Pair lock with the open state, and with unmount.

Skip link missing. Once you have a persistent sidebar or top bar, add a "Skip to main content" link as the first focusable control. Layout chrome without a skip link taxes keyboard users every navigation.

Pattern checklist before merge

  1. Breakpoints named in CSS (or tokens), not magic JSX numbers
  2. Mobile nav uses a real focus trap (primitive) when it is modal
  3. aria-expanded / aria-controls wired on the toggle
  4. Escape and backdrop dismiss both return focus
  5. Desktop sticky sidebar does not steal horizontal scroll from main
  6. Reduced-motion: drawer can appear without a long slide

If a PR fails three items on that list, it is not a layout polish, it is an accessibility bug with CSS.

Continuity

Accessible libraries buy behavior; responsive layout spends it on the shell. Forms, controlled vs uncontrolled inputs, and when a form library earns its dependency are the follow-on topics.

Takeaway

Responsive shells are breakpoints, focus, and CSS structure first - components second, and only for behavior you should not re-implement.