App Shell React component for Tailwind CSS v4
v1.1.0Updated in 1.11.0
A React app shell for admin layouts: a collapsible, resizable sidebar, a top bar and the page, with a drawer and a tab bar on phones.
On desktop the sidebar sits beside the page: press Mod+B (Cmd on a Mac, Ctrl elsewhere), the top bar's panel button or the sidebar's collapse button to fold it into a 64px icon rail (or, with collapsible="offcanvas", to slide it out completely); the width animates and the state is remembered in a cookie. With resizable, drag the sidebar's edge between 200 and 400px (the width follows the pointer and is saved on release), drag it below 120px to snap to the rail, double-click it to reset, or focus it with Tab and use Left / Right (16px), Home (rail), End (widest) and Enter (fold / unfold). Below the breakpoint the sidebar becomes a drawer: the top bar's menu button or Mod+B opens it, it closes on navigation, on Escape, on a tap on the dimmed page or by swiping it back, and focus returns to the menu button; with edgeSwipe a swipe from the start edge opens it too. The bottom tab bar shows on phones only. Tab once from the top of the page to reveal "Skip to content", which jumps straight to the page.
App Shell is a Pro React component in the React layout components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add app-shell, together with Sidebar, Topbar, Bottom Tab Bar, Sheet, Navigation and 7 more.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add app-shellApp Shell is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import AppShell from @/components/ui/app-shell.
import type { ReactNode } from "react"
import Link from "next/link"
import { cookies } from "next/headers"
import { usePathname, useRouter } from "next/navigation"
import { FileText, House, Inbox, LayoutDashboard, Settings, Users } from "lucide-react"
import { AppShell } from "@/components/ui/app-shell"
import { Sidebar, type SidebarNavGroup } from "@/components/ui/sidebar"
import { Topbar } from "@/components/ui/topbar"
import { persistedCookieName } from "@/hooks/use-persisted-state"
import { LinkProvider, type NavItem } from "@/lib/navigation"
// app/providers.tsx ("use client"): every NavLink inside renders through next/link
export function Providers({ children }: { children: ReactNode }) {
const router = useRouter()
return (
<LinkProvider component={Link} currentPath={usePathname()} navigate={router.push}>
{children}
</LinkProvider>
)
}
const nav: SidebarNavGroup[] = [
{
items: [
{ label: "Home", href: "/", icon: <LayoutDashboard /> },
{ label: "Inbox", href: "/inbox", icon: <Inbox />, badge: 4 },
],
},
{
label: "Sales",
items: [
{ label: "Clients", href: "/clients", icon: <Users /> },
{ label: "Invoices", href: "/invoices", icon: <FileText /> },
],
},
{ label: "Account", items: [{ label: "Settings", href: "/settings", icon: <Settings /> }] },
]
const tabs: NavItem[] = [
{ label: "Home", href: "/", icon: <House /> },
{ label: "Clients", href: "/clients", icon: <Users /> },
{ label: "Inbox", href: "/inbox", icon: <Inbox />, badge: 4 },
{ label: "Settings", href: "/settings", icon: <Settings /> },
]
// app/(app)/layout.tsx, a server component: the cookie renders the right sidebar on the first paint
export default async function AppLayout({ children }: { children: ReactNode }) {
const stored = (await cookies()).get(persistedCookieName("app-shell-collapsed"))?.value
return (
<AppShell
defaultCollapsed={stored === "true"}
sidebar={<Sidebar groups={nav} filter />}
topbar={<Topbar title="Summit CRM" />}
tabs={tabs}
labels={{ openMenu: "Open menu", closeMenu: "Close menu", collapse: "Collapse", expand: "Expand", navigation: "Main" }}
skipLinkLabel="Skip to content"
>
{children}
</AppShell>
)
}Props
sidebarReactNodeA Sidebar. The shell renders it in the desktop column (it drives its collapsed state, width and variant) and, on phones, a second copy with mode="sheet" inside the drawer, mounted only while the drawer is open. A wrapper component must forward its props (mode, variant, collapsible, width, className, style) to the Sidebar.
navSidebarNavGroup[]A shortcut for small apps: renders <Sidebar groups={nav} /> when sidebar is not given.
topbarReactNodeA Topbar, sticky at the top of the content column. It renders the menu button (sidebar toggle on desktop, hamburger on phones) and gets aria-controls and the shortcut hint from the shell unless you set menuControls / menuShortcut (pass the Topbar element itself, not a wrapper component, for that). For your own header use AppShellTrigger (see Parts).
tabsReactNode | NavItem[]Phones only: a BottomTabBar, or just its items. Hidden from mobileBreakpoint up. Fixed to the viewport with window scroll (the page is padded by --bottom-bar-offset), a static row under the page with content scroll.
bannerReactNodeA full-width strip above everything, e.g. an Alert with radius="none" for a maintenance notice.
childrenReactNodeThe page, rendered in <main id={mainId}>. The shell never animates it on navigation. Components inside it read the shell state with useAppShell (see Parts).
variantdefault"sidebar""sidebar" | "inset" | "floating"sidebar: flush columns with a hairline. inset: the page sits in a raised rounded panel on a tinted shell (Linear, Vercel). floating: the sidebar is a raised panel 8px off the edges. Sets the sidebar's variant unless it has its own.
defaultCollapseddefaultfalsebooleanStarts with the rail (or the sidebar hidden, with offcanvas). A stored cookie wins after hydration; read it on the server (persistedCookieName(`${persistKey}-collapsed`)) and pass it here so the first paint is right.
collapsedbooleanControlled collapsed state of the desktop sidebar. Pair it with onCollapsedChange.
onCollapsedChange(collapsed: boolean) => voidEvery change: the shortcut, the top bar button, the sidebar's collapse button, a drag to the rail.
collapsibledefault"icon""icon" | "offcanvas" | "none"icon: the sidebar folds into a 64px rail with tooltips. offcanvas: it slides out completely and the page takes the width. none: always expanded (the desktop toggle disappears; phones keep the drawer).
persistKeydefault"app-shell"stringRemembers collapsed and the width in the cookies `${persistKey}-collapsed` and `${persistKey}-width` (a server layout can read them). "" keeps both in memory.
defaultSidebarWidthdefault256numberThe expanded sidebar width in px, and what a double-click on the resize handle resets to.
sidebarWidthnumberControlled width in px. Pair it with onSidebarWidthChange.
onSidebarWidthChange(width: number) => voidFires once per change (pointer up, a key press, a reset), never per frame.
resizabledefaultfalsebooleanA handle on the sidebar's end edge: drag between 200 and 400px, below 120px it snaps to the rail, double-click resets. It is a keyboard-operable separator (arrows ±16px, Home, End, Enter).
mobileBreakpointdefault"md""md" | "lg"Below it the sidebar becomes a drawer and the tabs show: md (768px) or lg (1024px, tablets get the phone layout). Layout switches with CSS, so the server HTML is right.
defaultMobileOpendefaultfalsebooleanStarts with the phone drawer open (it only shows below the breakpoint).
mobileOpenbooleanControlled drawer state. Pair it with onMobileOpenChange.
onMobileOpenChange(open: boolean) => voidThe drawer opened or closed (menu button, shortcut, edge swipe, navigation, Escape, a tap outside, a swipe back).
mobileDrawerSizedefault"sm""sm" | "md"The drawer's width: 320 or 400px, always leaving a 48px sliver of the page to tap.
edgeSwipedefaultfalsebooleanPhones: a 16px strip on the start edge opens the drawer after a 40px drag or a flick. Turn it on in installed PWAs; in Safari that edge is the browser's back gesture.
scrolldefault"window""window" | "content"window: the document scrolls (router scroll restoration, the iOS toolbar minimizing; the right default for apps). content: the content column scrolls on its own and is provided to the bars as their scroll container (embedded apps, iframes, this demo). Docs-only: the playground always runs in content mode inside its frame.
heightdefault"screen""screen" | "full"screen: the viewport height (dvh). full: fills the parent, which needs a height. Docs-only: the playground frame always uses full.
shortcutdefault"mod+b"stringToggles the sidebar: the rail on desktop, the drawer on phones. Shown in the top bar button's tooltip. "" binds nothing.
skipLinkLabeldefault"Skip to content"stringThe skip link, the first focusable element: hidden until tabbed to, then it focuses the page.
mainIddefault"main"stringThe id of <main>, the skip link's target.
mainAsdefault"main""main" | "div"The content column's element. Keep main for the app's own shell; use div when the shell renders inside a page that already has its <main> (a docs preview, an embedded demo).
containerHTMLElement | nullWhere the drawer is portaled (default document.body).
labels{ openMenu?: string; closeMenu?: string; collapse?: string; expand?: string; resize?: string; collapsed?: string; navigation?: string }English defaults ("Open menu", "Close menu", "Collapse sidebar", "Expand sidebar", "Resize sidebar", "Collapsed", "Navigation"). The first four are shared with the top bar's menu button through the shell context; resize names the resize handle, collapsed is its value text while the sidebar is a rail, navigation is the drawer's hidden title.
classNamesPartial<Record<"sidebar" | "body" | "main" | "topbar" | "tabs" | "drawer" | "resize", string>>Extra classes for the parts: the desktop sidebar column, the content column (the raised panel in inset), <main>, the top bar, the tabs row, the drawer body and the resize handle.
classNamestringExtra classes for the root.
Parts
useAppShell
hookThe shell state for anything inside it: isMobile, collapsed / setCollapsed, mobileOpen / setMobileOpen, toggleSidebar, hasSidebar, scroll, labels, mainRef, sidebarWidth / setSidebarWidth, shortcut, and sidebarId / drawerId for aria-controls. Throws outside an AppShell.
AppShellTrigger
componentThe menu button for your own header: the sidebar toggle with a tooltip on desktop, a hamburger that turns into an X on phones, nothing when the shell has no sidebar. Takes the Button props plus kind, shortcut, tooltip and labels; aria-controls and the shortcut come from the shell.
Examples
Inset
variant="inset": the page sits in a raised rounded panel on a tinted shell, and the top bar glass matches the panel.
Floating, off-canvas
variant="floating" + collapsible="offcanvas": the panel button in the top bar slides the whole sidebar out and the page takes the width.
Resizable, remembered
Drag the sidebar's edge (or focus it and use the arrows); below 120px it snaps to the rail, double-click resets. The width survives a reload.
No sidebar: top bar and tabs
A mobile-first app: links in the top bar on wide screens, a bottom tab bar on phones. No menu button, no drawer, no shortcut.
Maintenance banner
banner: a full-width strip above the shell (an Alert with banner), here with the sidebar starting as a rail.
On a phone: drawer and tabs
The same app at 390px with the drawer open: tap the page or swipe the drawer back, and it closes on navigation.
Dependencies
Files
- components/ui/app-shell.tsx
FAQ
Is the React App Shell component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install App Shell?
Run npx wingo-ui@latest add app-shell. It also adds the Wingo UI items it builds on (Sidebar, Topbar, Bottom Tab Bar, Sheet, Navigation and 7 more). With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/app-shell.
How does App Shell behave on phones?
The bottom tab bar shows on phones only.
See also
Used in
More React layout components
Articles using this component
- Nextjs PWA Without next-pwa: Offline Page and App Shell
Mobile UI in React12 min read
- Shadcn Sidebar Mobile: Fix the Sheet and Add a Tab Bar
Mobile UI in React9 min read
- Best React Component Library for Dashboard Apps in 2026
Comparisons and alternatives13 min read
- 100vh Mobile Fix: When to Use dvh, svh or lvh in Tailwind
Mobile UI in React11 min read