PWA UIv0.1 beta
Docs / Guides / App layout

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.

Composition
PWAProvider└── Application root containment    └── AppShell        ├── AppShell.Header        │   └── NavigationBar        ├── AppShell.Main        │   └── StackNavigator or routed screen content        └── AppShell.Footer            └── TabBar

Installation

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.

Terminal
pnpm dlx shadcn@latest add \  @pwa-ui/pwa-provider \  @pwa-ui/app-shell \  @pwa-ui/stack-navigator \  @pwa-ui/navigation-bar \  @pwa-ui/tab-bar \  @pwa-ui/safe-area

Responsibilities

LayerOwnsDoes not own
PWAProviderViewport and keyboard measurementsDocument scrolling or visual chrome
Application rootContaining document scroll for full-screen appsScreen regions or navigation
AppShellOne visual-viewport frame and region placementNavigation appearance or routing
AppShell.MainThe application’s vertical scrollingPersistent header or footer chrome
StackNavigatorMounted view state, focus, and push/pop presentationURLs, history, route matching, or data loading
NavigationBarTop navigation content and actionsSafe-area placement or viewport height
TabBarPrimary destinations and active stateFooter placement or document scrolling
SafeAreaInsets explicitly requested by custom layoutsInsets 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.

Opt in only for application surfaces. Leave these attributes off documentation, marketing, and embedded pages that should use normal document scrolling.

Next.js

app/layout.tsx
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
<!-- 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.

app/layout.tsx
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.

application-layout.tsx
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.Header applies the top inset. Do not add another top SafeArea around NavigationBar.
  • AppShell.Footer applies the bottom inset. Do not add another bottom SafeArea around TabBar.
  • Use SafeArea for 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.

Stacked navigation

Use StackNavigator for drill-in flows where a detail screen should cover a list and reveal it unchanged on back. The array is the complete source of truth: appending pushes, removing the last entry pops, and onPop only asks the consumer to make that change.

State-driven stack
"use client"
import * as React from "react"import { StackNavigator } from "@/components/ui/stack-navigator"
export function Projects() {  const [selectedProject, setSelectedProject] = React.useState<string | null>(null)  const entries = [    { key: "projects", content: <ProjectList onSelect={setSelectedProject} /> },    ...(selectedProject ? [{ key: selectedProject, content: <Project id={selectedProject} /> }] : []),  ]
  return <StackNavigator entries={entries} onPop={() => setSelectedProject(null)} />}

With Next.js App Router

Keep route ownership in Next.js. A client wrapper can receive the list and optional detail slot as React nodes, map the rendered route state to entries, and call router.back() from onPop. Links or router.push() still perform navigation; StackNavigator only presents the result.

Client route adapter
"use client"
import { useRouter } from "next/navigation"import { StackNavigator } from "@/components/ui/stack-navigator"
export function RoutedProjectStack({ list, detail, detailKey }) {  const router = useRouter()  const entries = [    { key: "projects", content: list },    ...(detail ? [{ key: detailKey, content: detail }] : []),  ]
  return <StackNavigator entries={entries} onPop={() => router.back()} />}

Parallel-route slots preserve their active subpage during soft navigation, but a hard reload needs a matching default.tsx fallback. Intercepted routes are useful when the routed detail should overlay its originating context; they are not required for a normal state-driven stack. In either pattern, pass the router-rendered nodes into StackNavigator rather than adding pathname or history logic to the registry component.

Edge-swipe back policy

backGesture="auto" is the default and the recommended starting point. It enables the leading-edge gesture only when the app is running in standalone or fullscreen display mode. In an ordinary Safari tab, the browser already owns that edge for history navigation, so the component stays out of the way.

  • Use on only for a kiosk, embedded surface, or other environment where you have verified the edge is unclaimed.
  • Use off for surfaces with a horizontal edge interaction, an RTL right-edge requirement, or any unresolved OS gesture conflict.
  • Android system Back remains router territory. Update the route or controlled entries from the platform event; do not synthesize a pointer swipe.

The gesture begins inside a 24-pixel edge zone after the left safe-area inset, waits for horizontal intent, and leaves vertical-first movement alone. A distance past half the stack width or a deliberate velocity flick commits the same controlled onPop request as the back button. Use onBackGestureStateChange or data-back-gesture-state when custom chrome should react during tracking.

Common mistakes

  • Allowing both the document and AppShell.Main to 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.
  • Letting StackNavigator and the router both maintain separate navigation histories.
  • Using 100vh wrappers outside the shell that can exceed the usable mobile visual viewport.