Build a mobile shell with bottom tabs
By the end of this recipe you'll have the chrome every Huchu mobile portal lives inside — four bottom tabs with brand-pill active state, badge counts, safe-area-inset for the iPhone home bar, slide-up entrance, and a graceful hide when the soft keyboard pushes up.
Overview
Four tabs, pinned to the bottom, surviving safe areas and the soft keyboard. One <MobileShell>, one route, one rule: never more than four.
The mobile bottom-tab shell is what the parent portal, the cashier app, and the field-ops PWA all share. Bottom tabs win on mobile because they sit under the thumb — the operator can switch surface without re-gripping the phone. They lose the moment you put more than four on screen: the brand-pill active state stops being legible and the user starts hunting. The fifth tab is always "More", and "More" is a half-sheet that opens from the tab strip, not a separate page.
The shell handles five responsibilities: layout (header optional, scrollable content, fixed tab strip); safe areas (env(safe-area-inset-bottom) on iOS); active state (brand-pill behind the active icon + label); badges (per-tab unread count from your store); and keyboard awareness (slide the strip out of the way when an input is focused, so it doesn't sit on top of the keyboard). The slide-up entrance is the same motion as the canonical 2FA AuthShell, so the operator feels they're stepping into one app, not three.
Skip this recipe if your mobile experience is single-screen — a settings PWA, a one-form intake. A tab bar on a single-screen app just steals 56 px of real estate.
What you'll build
Three configurations of the same shell. The body changes; the strip is identical.
You can see this live in
Every Huchu mobile portal uses this shell. Pick one to see the tabs and active-state pill in context.
Required pieces
Everything this recipe pulls from @corelithzw/react. Click any to see its reference page.
@corelithzw/react exports used here: MobileShell, MobileShell.Header, MobileShell.Body, MobileShell.Tabs, Tab, Badge, BottomSheet. The MobileShell.* dot-namespace mirrors the desktop AppShell.* shape — same mental model, two breakpoints.
Step-by-step build
Lay out three rows: header, body, tab strip
The shell is a column flex with three children: header (optional), body (scrollable, takes the remaining space), tabs (fixed at the bottom). Use min-height: 100dvh on the root so the iOS URL bar doesn't push your tabs off screen on the first scroll. Pad the strip with env(safe-area-inset-bottom) so the home indicator never overlaps a label.
Body absorbs the safe area at the top; tabs absorb it at the bottom.
100dvh not 100vh — survives the iOS URL bar.
Switch to the Code tab to see the snippet.
Render four tabs with brand-pill active state and a badge
Tabs render as <button>s inside a nav. Active state is a brand-soft rounded background behind icon + label — never just a colour change, which fails for low-vision users. The badge sits on the icon, not the label, so it stays legible even when the tab is wide enough to wrap.
Hide the strip when the soft keyboard is up
On iOS, a focused input pushes the visual viewport upward and your sticky tabs end up floating in the middle of the screen. Listen to visualViewport.resize and translate the strip down by its own height when the viewport shrinks. On Android, focus-in / focus-out on inputs is the more reliable signal — listen to both.
hidden → translateY(100%), 180 ms.
Keep the listeners at shell-level — never inside individual fields.
Switch to the Code tab to see the snippet.
Spill the 5th+ tab into a "More" bottom sheet
The moment your app has five tabs you want to show, drop the 5th into a "More" tab that opens a BottomSheet from the strip. The sheet lists the overflow items as a tall scrolling list and inherits the same active state. Never collapse to fewer-than-4 — three tabs on a phone-wide strip leave dead space the user reads as missing nav.
Final composition
The whole MobileShell wrapper, assembled. Slots, tab definitions, keyboard awareness, badge wiring, More-sheet, slide-up entrance.
Variations
Three forks. Each is a small diff from the final composition — the slot shape stays the same.
Three tabs, no More
App has 3 surfaces, not 5. Drop the overflow + sheet; the strip stretches evenly. Useful for the cashier PWA.
// Three primary tabs, no overflow
const PRIMARY = [
{ id: 'sell', icon: 'bag', to: '/' },
{ id: 'till', icon: 'cash', to: '/till' },
{ id: 'reports', icon: 'chart', to: '/reports' },
];
Floating action tab
The centre tab is a primary action ("New sale", "Add receipt") rather than a route. Render a raised circle that opens a sheet of actions.
<Tab
icon="plus"
raised
onClick={() => setNewSheet(true)}
aria-label="Add"
/>
Label-less compact
Five icons, no labels — for a power-user app where the operator knows the icons by heart. Saves 14 px of vertical room.
<MobileShell.Tabs compact aria-label="Primary">
{TABS.map(t => (
<Tab key={t.id} icon={t.icon}
aria-label={t.label} />
))}
</MobileShell.Tabs>
Accessibility
What this recipe takes care of for you. Each item is something a screen-reader or keyboard user will notice if you skip it.
- Tabs are a real nav landmark.
MobileShell.Tabsrenders<nav aria-label="Primary">so SR users can jump to it from the rotor. - Active tab announces "current page".
aria-current="page"on the active tab — SR reads "Home, current page" instead of just "Home". - More tab declares expanded state.
aria-expanded={moreOpen}so the SR knows the sheet is open without the user opening it again. - Badges have a label.
Badgerendersaria-label="3 unread"— the number alone reads as "3", which gives the user nothing. - Tabs never sit on top of inputs. Keyboard-open hook moves the strip off-screen so focused inputs aren't obscured by an immovable element.
- Safe-area is respected.
padding-bottom: max(8px, env(safe-area-inset-bottom))keeps labels clear of the iPhone home indicator. - Touch targets are 44 × 44. The smallest tappable area for an icon-only tab clears Apple's HIG and Material's a11y minimum — operators with motor impairment can still hit them.