Build an application shell
One chooser, two chromes, and the arithmetic that lets a rail collapse without anything moving — so a navigation design can be tried rather than argued about.
Use this when establishing an authenticated application, or when a navigation design is disputed and you want to settle it by looking at both.
1. Put every page behind one chooser
export function AppShell({ children }: { children: ReactNode }) {
const [variant] = useNavigationVariant()
return variant === "bar"
? <TopBarShell>{children}</TopBarShell>
: <SidebarShell>{children}</SidebarShell>
}Every authenticated page goes through AppShell, directly or via a layout route.
No page imports a specific shell and none can tell which one wraps it. That single
fact is what makes two navigation designs comparable on the same screen with the
same content, instead of on two screenshots taken a week apart.
Keep the losing variant as source, not as a comment. A commented variant stops compiling against the codebase within a week and is no longer comparable — which means the comparison never actually happens and the first design wins by attrition.
If trying a variant requires touching page code, it is not a shell change and the experiment will be run once and never again.
2. Store the choice on the device, not the account
Theme, language, navigation variant, a collapsed rail. These belong to the machine
in front of the person. They live in localStorage, touch no database, and
therefore commit the product to nothing — which is what makes them the right home
for an experiment. A preference written to the account is a product decision; do not
take one to run a trial.
function useStoredChoice<T extends string>(
key: string,
parse: (value: string | null) => T,
): [T, (next: T) => void] {
const [choice, setChoice] = useState<T>(() => parse(null)) // the server's answer
useEffect(() => {
setChoice(parse(window.localStorage.getItem(key))) // the machine's answer
const sync = (event: StorageEvent) => {
if (event.key === key) setChoice(parse(event.newValue))
}
window.addEventListener("storage", sync)
return () => window.removeEventListener("storage", sync)
}, [key, parse])
const choose = useCallback((next: T) => {
window.localStorage.setItem(key, next)
setChoice(next)
// `storage` never fires in the tab that wrote it. Re-emit, or every other
// surface on this page keeps showing the old value.
window.dispatchEvent(new StorageEvent("storage", { key, newValue: next }))
}, [key])
return [choice, choose]
}Initial state is the default, always — useState(() => parse(null)), never a
read of localStorage. The second form renders one thing on the server and another
on the client, and fails hydration on the first paint. Read in an effect; the cost
is a one-frame flash for whoever's choice differs from the default, so make the
default the value most people hold.
parse is total. Storage is user-writable and outlives deployments; a value
your code stopped supporting is a value you will still read. Map everything unknown
onto the default.
Surface the switch where the other device preferences live — the same settings panel as theme and language, not behind a build flag. You do not compare two shells by restarting between them.
3. Lay out the rail
┌──────────────┐
│ mark toggle │ head: identity, and the control that sets the chassis
│ │
│ ▸ item │ nav: groups, one heading each
│ ▸ item │
│ │
│ account │ foot: the account menu
└──────────────┘The collapse control belongs in the head, beside the mark. It sets the chassis, not the navigation; at the foot, between the links and the account, it reads as one more destination.
Collapsed, the head stacks rather than aligning. A 68px rail will not hold a mark and a control side by side, and the one thing that must never disappear is the control that re-expands.
4. Make the collapse move nothing
The part that is not obvious, and the reason a collapse feels like a panel closing rather than a re-layout.
Every horizontal inset is identical in both states. The rail loses the width the
words occupied, and nothing else. If padding changes between states — px-2
collapsed, px-3 expanded — every icon slides sideways on the click.
Then choose the insets so the collapsed rail centres itself without a centring rule existing:
rail (collapsed) 68px
icon 24px
inset = (68 − 24) / 2 = 22px → 12px on the <nav> + 10px on the row
icon centre = 22 + 12 = 34px = 68 / 2 ✓
account disc 36px
inset = (68 − 36) / 2 = 16px → 12px on the foot + 4px on the trigger
disc centre = 16 + 18 = 34px ✓Everything lands on the rail's own axis when collapsed and has not moved when
expanded. No justify-center, no conditional alignment, nothing to animate.
Changing an icon size means redoing this. One subtraction — and write the numbers in the file, because the next person to resize an icon will not derive them again.
In a rail, the account disc leads its trigger. Trailing the name it travels about 150px on collapse. In a horizontal bar, name-then-disc is right; make it a prop, not a fork.
Animate the width, not the contents:
"transition-[width] duration-200 ease-out motion-reduce:transition-none"with the matching transition-[padding] on the content column and overflow-hidden
on the rail so labels clip instead of reflowing. See
motion and polish for the easing and duration
choice.
5. Keep a bar under the rail
A rail and a top bar are not redundant — they answer different questions. The rail says where you can go; the bar says where you are, which is the breadcrumb and the page's name. Only the first is answered by highlighting a link.
Split the chrome cleanly: identity and navigation in the rail, location in the bar, each utility belonging to exactly one of them.
The bar keeps the page gutters, never the rail's. Optical recentring applies to the reading column alone; applied to the bar it drags corner utilities out of their corner.
6. Recentre the content with a gutter, never a transform
A page centred in what remains after a 248px rail sits half the rail's width right of the window centre. To pull it back:
<main className="flex-1 pr-[calc(var(--app-sidebar-w)/2)]">A right gutter of n moves a centred block left by n/2 and can never overflow. A
translate-x of the same amount slides the page under the rail as soon as the
window is narrow enough that the block has less slack than the shift — and at common
widths a 1088px measure has about 50px of slack against a 124px shift.
The gutter costs measure while the window is narrow and costs nothing once the page reaches its measure without it. Half the rail width is usually right; the full width reads as a misalignment rather than as centring.
7. Collapse permanently on small screens
Below the width where a 248px rail takes a quarter of the screen, force the icon
rail. Hide the collapse control there — a control that changes nothing is a lie —
and move labels to title and sr-only.
Finished when
- Every authenticated page renders through one shell component and imports none of them directly.
- Switching variant changes no page file.
- Collapsing and expanding moves no icon horizontally.
- The preference survives a reload, follows a second tab, and hydrates without a mismatch.