Home/ Cookbook/ Shells & nav/ Mobile bottom-tab shell

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.

Shells 2 primitives · 1 block · 0 patterns ~20 min React · @corelithzw/react

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.

Home
Fees
3
Notices
Profile
01 Four tabs with brand-pill active + badge
Home
Fees
Notices
Marks
More
02 Five tabs, last one is "More" → sheet
[ on-screen keyboard ]
03 Keyboard up → tab strip hides

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

01

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.

Code-only step

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.

Step 1 · Layout + safe area@corelithzw/react
02

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.

Home
Fees
3
Notices
Profile
Step 2 · Tab strip
03

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.

Code-only step

hiddentranslateY(100%), 180 ms.

Keep the listeners at shell-level — never inside individual fields.

Switch to the Code tab to see the snippet.

Step 3 · Keyboard awareness
04

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.

More
Marks
Attendance
Library
Calendar
Home
Fees
Notices
More
Step 4 · More sheet

Final composition

The whole MobileShell wrapper, assembled. Slots, tab definitions, keyboard awareness, badge wiring, More-sheet, slide-up entrance.

Mukamba School
Home
Fees
3
Notices
More
MobileTabShell.tsx@corelithzw/react

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.Tabs renders <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. Badge renders aria-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.