Guide
App layout
Build a full-screen application frame with persistent navigation, one predictable scroll region, and safe-area behavior that is applied exactly once.
Mental model
Each layer owns one concern. The provider measures the viewport, the application root contains document scrolling, AppShell divides the viewport, and the navigation components render inside its placement regions.
PWAProvider└── Application root containment └── AppShell ├── AppShell.Header │ └── NavigationBar ├── AppShell.Main │ └── Routed screen content └── AppShell.Footer └── TabBarInstallation
Install the shared provider and the layout components together. The registry dependencies also install the PWA UI base stylesheet; import that stylesheet once as shown below.
pnpm dlx shadcn@latest add \ @pwa-ui/pwa-provider \ @pwa-ui/app-shell \ @pwa-ui/navigation-bar \ @pwa-ui/tab-bar \ @pwa-ui/safe-areaResponsibilities
| Layer | Owns | Does not own |
|---|---|---|
| PWAProvider | Viewport and keyboard measurements | Document scrolling or visual chrome |
| Application root | Containing document scroll for full-screen apps | Screen regions or navigation |
| AppShell | One visual-viewport frame and region placement | Navigation appearance or routing |
| AppShell.Main | The application’s vertical scrolling | Persistent header or footer chrome |
| NavigationBar | Top navigation content and actions | Safe-area placement or viewport height |
| TabBar | Primary destinations and active state | Footer placement or document scrolling |
| SafeArea | Insets explicitly requested by custom layouts | Insets AppShell already applies |
Contain the application root
A full-screen application should not let the browser document become a second scroll container. Import the PWA UI base stylesheet, add data-pwa-app-root to the document, and mark the element that directly contains your application.
Next.js
import { PWAProvider } from "@/components/ui/pwa-provider"import "@/styles/pwa.css"
export default function RootLayout({ children }) { return ( <html lang="en" data-pwa-app-root> <body> <PWAProvider> <div data-pwa-app-mount>{children}</div> </PWAProvider> </body> </html> )}Vite or React
<!-- index.html --><html lang="en" data-pwa-app-root> <body> <div id="root" data-pwa-app-mount></div> <script type="module" src="/src/main.tsx"></script> </body></html>Enable safe-area insets
Use viewport-fit=cover so installed and edge-to-edge browser surfaces expose their safe-area environment variables. In Next.js, export the viewport configuration from your root layout.
import type { Viewport } from "next"
export const viewport: Viewport = { width: "device-width", initialScale: 1, viewportFit: "cover",}For a static HTML entry, add viewport-fit=cover to the viewport meta tag instead.
Compose the shell
Place visual navigation inside the shell regions. Keep routed screen content inside Main so navigation remains anchored while content changes and scrolls.
import Link from "next/link"import { Home, Search, User } from "lucide-react"
import { AppShell } from "@/components/ui/app-shell"import { NavigationBar } from "@/components/ui/navigation-bar"import { TabBar } from "@/components/ui/tab-bar"
export function ApplicationLayout({ children }) { return ( <AppShell> <AppShell.Header> <NavigationBar> <NavigationBar.Title>Field Notes</NavigationBar.Title> </NavigationBar> </AppShell.Header>
<AppShell.Main>{children}</AppShell.Main>
<AppShell.Footer keyboardBehavior="hide"> <TabBar> <TabBar.Item render={<Link href="/" />} icon={<Home />} label="Home" active /> <TabBar.Item render={<Link href="/search" />} icon={<Search />} label="Search" /> <TabBar.Item render={<Link href="/profile" />} icon={<User />} label="Profile" /> </TabBar> </AppShell.Footer> </AppShell> )}Safe-area ownership
AppShell.Headerapplies the top inset. Do not add another top SafeArea around NavigationBar.AppShell.Footerapplies the bottom inset. Do not add another bottom SafeArea around TabBar.- Use
SafeAreafor standalone custom chrome or for explicit left and right protection in landscape layouts. - Keep ordinary content spacing inside a child element when it must be additive to a safe-area inset.
Routing and persistence
Mount PWAProvider and the application shell in a persistent router layout, then render the route outlet inside AppShell.Main. Pass the active route to TabBar and screen-specific titles or actions to NavigationBar without remounting the frame.
Common mistakes
- Allowing both the document and
AppShell.Mainto scroll. - Placing NavigationBar or TabBar beside AppShell instead of inside its Header or Footer.
- Applying the same safe-area edge in both AppShell and SafeArea.
- Mounting a separate AppShell for every route when the navigation chrome should persist.
- Using
100vhwrappers outside the shell that can exceed the usable mobile visual viewport.