responsive chrome adapts by platform from one item source
why
a product shipped as desktop web, mobile web, and a Capacitor-wrapped native app has three chrome surfaces competing for the same navigation truth. two failure modes recur:
- top nav and bottom dock each author their own item list and drift out of sync as features are added
- the bottom dock grows one tab per feature until a few modules become an unusable row of cramped icons
one item source with two renderers, plus a fixed small dock slot count with an overflow surface for everything else, keeps navigation truthful and lets the feature count grow without touching the chrome again.
rule
- define navigation items once, in one file, as data — not JSX. each item carries everything either renderer needs: route, icon, full desktop label, short dock label (narrow tabs tolerate far less text), and overflow/grid fields (card title, one-line description). both surfaces read the same list; a new feature adds one entry and both pick it up.
- the dock is a fixed number of slots: home, an overflow entry opening the full item list, and a few app-wide cross-cutting destinations (account, in-progress-work list, assistant/help if one exists). it never grows one tab per module — module destinations live behind the overflow slot as a grid or list from the same item source. the top nav has real horizontal room and renders every item directly.
- platform branching happens once, at the chrome-mounting layer, not inside each navigation surface: native shells almost always want the dock regardless of viewport width (there is no "desktop" native target), while web switches between top nav and dock at a breakpoint. one condition at the point the two surfaces are chosen — not scattered per-component visibility classes.
safe areas are a platform concern, not a per-feature concern
a Capacitor app with viewport-fit=cover extends under the status bar/notch
and home indicator. reserve both unconditionally at the chrome layer: top
inset above content when the dock replaces a native top bar, bottom inset
below content and inside the dock itself. use the platform's safe-area
environment variables in the chrome's own padding, never
component-by-component — a screen author should not need to know whether
their content runs under a notch.
construction
- one
navItemsdata file:{ to, label, dockLabel, cardLabel, description, icon }[]— data only, nothing about rendering. - desktop/wide chrome renders every item directly from the list.
- narrow/native chrome renders a fixed slot set: home, an overflow trigger, the handful of true cross-cutting destinations — never one slot per entry.
- the overflow trigger opens a sheet or sub-page mapping the full
navItemslist to cards or rows (card label + description). - safe-area padding once at the root layout: top inset for content when a native notch applies; bottom inset for the scrollable content (the fixed dock must never occlude it) and for the dock's own bottom edge.
- locale or copy changes for navigation only ever touch the
navItemssource; neither renderer carries its own copy.
verification
- add a new module entry to
navItems; it appears in the top nav AND the overflow surface with no other edits. - the dock's slot count is unchanged after adding that entry.
- capture desktop width, mobile web width, and a notched simulator profile: content never renders under a notch, dock never occludes bottom content.
- native always renders the dock regardless of width; web switches at the chosen breakpoint.
- grep for hardcoded navigation copy or icons outside the item source — none.
good and bad
bad
// TopNav.tsxconst items = [{ to: "/visa", label: "Visa Shield" },{ to: "/passport", label: "Passport" },];// BottomDock.tsx — a second, independently maintained list that WILL driftconst tabs = [{ to: "/visa", label: "visa" },{ to: "/passport", label: "passport" },{ to: "/overstay", label: "overstay" },{ to: "/interpreter", label: "interp" },// every new module adds another cramped tab, forever];
two sources of truth guarantee drift the first time one is updated without the other, and the dock has no ceiling — it degrades linearly with the product's growth.
good
// navItems.ts — the one sourceexport const navItems = [{ to: "/visa-shield", label: () => m.nav_visa_shield(), dockLabel: () => m.nav_visa_shield_dock(), cardLabel: () => m.vs_kicker(), description: () => m.home_visa_short(), icon: ShieldCheck },// ...];// BottomDock.tsx — fixed slots, overflow reads the same source<nav><DockLink to="/" icon={House} label="home" /><DockButton onClick={() => setSheet("services")} icon={LayoutGrid} label={m.dock_services()} /><DockLink to="/cases" icon={ClipboardList} label={m.dock_cases()} /><DockButton onClick={() => setSheet("account")} icon={User} label={m.dock_account()} /></nav><ServicesSheet items={navItems} /> {/* every module lives here, dock never grows */}
one list feeds both renderers; the dock stays four slots whether the product has four modules or forty.