Mobile Shell

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

A
Home item 1Header and nav stay pinned.
B
Home item 2Header and nav stay pinned.
C
Home item 3Header and nav stay pinned.
D
Home item 4Header and nav stay pinned.
E
Home item 5Header and nav stay pinned.
title
tab
<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

A
Home item 1Header and nav stay pinned.
B
Home item 2Header and nav stay pinned.
C
Home item 3Header and nav stay pinned.
D
Home item 4Header and nav stay pinned.
E
Home item 5Header and nav stay pinned.
F
Home item 6Header and nav stay pinned.
G
Home item 7Header and nav stay pinned.
H
Home item 8Header and nav stay pinned.
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.json

Usage

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

PropTypeDefaultDescription
childrenReactNode–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.
prefixReactNode–A control leading the title — usually a back button. Sits in a side column with a 40px touch-target floor.
suffixReactNode–A control trailing the title — usually an action button or menu.

MobileNavItem

PropTypeDefaultDescription
labelReactNode–Accessible name for the icon-only tab (aria-label).
iconReactNode–Tab icon; svgs are sized to size-6 automatically.
hrefstring–When set, the item renders a Next.js Link instead of a button.
activeboolean–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.
ComponentDescription
MobileShellFixed, full-viewport flex column (fixed inset-0, overflow-hidden) on the app background (data-slot="mobile-shell").
MobileShellHeaderPinned 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").
MobileShellContentThe scrolling main element – min-h-0 flex-1 with native momentum scrolling (data-slot="mobile-shell-content").
MobileNavBottom 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").
MobileNavItemOne 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.

Search documentation

Search the component docs