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

  1. one navItems data file: { to, label, dockLabel, cardLabel, description, icon }[] — data only, nothing about rendering.
  2. desktop/wide chrome renders every item directly from the list.
  3. narrow/native chrome renders a fixed slot set: home, an overflow trigger, the handful of true cross-cutting destinations — never one slot per entry.
  4. the overflow trigger opens a sheet or sub-page mapping the full navItems list to cards or rows (card label + description).
  5. 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.
  6. locale or copy changes for navigation only ever touch the navItems source; neither renderer carries its own copy.

verification

  1. add a new module entry to navItems; it appears in the top nav AND the overflow surface with no other edits.
  2. the dock's slot count is unchanged after adding that entry.
  3. capture desktop width, mobile web width, and a notched simulator profile: content never renders under a notch, dock never occludes bottom content.
  4. native always renders the dock regardless of width; web switches at the chosen breakpoint.
  5. grep for hardcoded navigation copy or icons outside the item source — none.

good and bad

bad

// TopNav.tsx
const items = [
{ to: "/visa", label: "Visa Shield" },
{ to: "/passport", label: "Passport" },
];
// BottomDock.tsx — a second, independently maintained list that WILL drift
const 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 source
export 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.

search pages

go to any page