Mobile Shell
A fixed mobile app frame with a pinned header, scrolling content area, and bottom tab bar. Render it below the md breakpoint via useIsMobile.
Playground
Inbox
<MobileShell>
<MobileShellHeader>Inbox</MobileShellHeader>
<MobileShellContent>Scrollable page content</MobileShellContent>
<MobileNav>
<MobileNavItem label="Home" icon={<House />} active />
<MobileNavItem label="Search" icon={<Search />} />
<MobileNavItem label="Alerts" icon={<Bell />} />
<MobileNavItem label="Profile" icon={<User />} />
</MobileNav>
</MobileShell>Preview
A MobileShell stacks MobileShellHeader, MobileShellContent and a MobileNav of MobileNavItem tabs. Scroll the list – the header and nav stay pinned. (Shown here inside a small device frame; in an app the shell is fixed to the viewport — relative h-full scopes it to a frame.)
home
const [active, setActive] = useState("home")
<MobileShell>
<MobileShellHeader>
<span className="capitalize">{active}</span>
</MobileShellHeader>
<MobileShellContent className="flex flex-col gap-2 p-3">
{items.map((item) => (
<ListRow key={item.id} item={item} />
))}
</MobileShellContent>
<MobileNav>
{tabs.map((tab) => (
<MobileNavItem
key={tab.id}
label={tab.label}
icon={tab.icon}
active={active === tab.id}
onClick={() => setActive(tab.id)}
/>
))}
</MobileNav>
</MobileShell>Installation
npx shadcn@latest add https://espresso-base-ui.vercel.app/r/mobile-shell.jsonUsage
import {
MobileNav,
MobileNavItem,
MobileShell,
MobileShellContent,
MobileShellHeader,
} from "@/components/ui/mobile-shell"<MobileShell>
<MobileShellHeader>Title</MobileShellHeader>
<MobileShellContent>Scrollable page content</MobileShellContent>
<MobileNav>
<MobileNavItem label="Home" icon={<House />} href="/" active />
<MobileNavItem label="Search" icon={<Search />} href="/search" />
</MobileNav>
</MobileShell>Pairing with the desktop layout
Switch between the shell and the desktop Sidebar layout at the md breakpoint with useIsMobile – the shell replaces the sidebar wholesale rather than collapsing it.
const isMobile = useIsMobile()
if (isMobile) {
return (
<MobileShell>
<MobileShellHeader>Inbox</MobileShellHeader>
<MobileShellContent>{children}</MobileShellContent>
<MobileNav>{tabs}</MobileNav>
</MobileShell>
)
}
return <DesktopSidebarLayout>{children}</DesktopSidebarLayout>API reference
The frame parts render semantic elements – header, main and nav – and accept className plus their standard element props. MobileShellHeader and MobileNavItem add props of their own.
MobileShellHeader
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | – | The title — centered on a single line and truncated; the header height is fixed (--mobile-header-height, 56px) so a long title never reflows the page. |
| prefix | ReactNode | – | A control leading the title — usually a back button. Sits in a side column with a 40px touch-target floor. |
| suffix | ReactNode | – | A control trailing the title — usually an action button or menu. |
MobileNavItem
| Prop | Type | Default | Description |
|---|---|---|---|
| label | ReactNode | – | Accessible name for the icon-only tab (aria-label). |
| icon | ReactNode | – | Tab icon; svgs are sized to size-6 automatically. |
| href | string | – | When set, the item renders a Next.js Link instead of a button. |
| active | boolean | – | Highlights the item and sets aria-current, independently of the URL. Tapping the active item scrolls the shell content back to the top instead of re-navigating. |
| Component | Description |
|---|---|
| MobileShell | Fixed, full-viewport flex column (fixed inset-0, overflow-hidden) on the app background (data-slot="mobile-shell"). |
| MobileShellHeader | Pinned fixed-height header (--mobile-header-height, 56px) with a centered single-line title and prefix/suffix slots; clears the top safe-area inset in an installed PWA (data-slot="mobile-shell-header"). |
| MobileShellContent | The scrolling main element – min-h-0 flex-1 with native momentum scrolling (data-slot="mobile-shell-content"). |
| MobileNav | Bottom tab bar nav element – a 56px equal-width grid with a top border on the card surface; clears the home indicator in an installed PWA (data-slot="mobile-nav"). |
| MobileNavItem | One icon-only tab filling the 56px bar (data-slot="mobile-nav-item") – a size-6 icon with a press-scale; label becomes the aria-label. Renders a Link when href is set and inactive, otherwise a button. |
Accessibility & styling hooks
The shell uses landmark elements (header, main, nav) so assistive tech can jump between regions, and the active tab announces itself with aria-current="page". The active state is also reflected as data-active="true" on the item, alongside the data-slot attributes on every part – target these from CSS for app-level overrides. Header and nav respect the device safe-area insets via env(safe-area-inset-*) padding when running as an installed PWA.