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:
- Closed by default on mobile
- Toggle in the top bar with an accessible name
- Focus moves into the drawer when opened
- Escape / backdrop closes and returns focus to the toggle
aria-expandedon 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
| Need | Prefer |
|---|---|
| Two-column shell, sticky nav | CSS grid + sticky |
| Collapsing gaps / alignment | Flexbox |
| Modal mobile nav with focus trap | Accessible primitive |
| Resizable panels | Dedicated splitter (pointer + keyboard) |
| Marketing page sections | CSS; 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
- Breakpoints named in CSS (or tokens), not magic JSX numbers
- Mobile nav uses a real focus trap (primitive) when it is modal
aria-expanded/aria-controlswired on the toggle- Escape and backdrop dismiss both return focus
- Desktop sticky sidebar does not steal horizontal scroll from main
- 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.