The frame of an application page: skip link, sticky header, sidebar that becomes a sheet on small screens, main area, optional aside and footer.
"use client";
import { BarChart3, FolderKanban, Home, Inbox, Settings, Users } from "lucide-react";
import {
AppShell,
AppShellAside,
AppShellFooter,
AppShellHeader,
AppShellMain,
AppShellSidebar,
AppShellSidebarTrigger,
AppShellSkipLink,
} from "@/components/ballmac/app-shell";
const nav = [
{ label: "Overview", icon: Home, active: true },
{ label: "Projects", icon: FolderKanban },
{ label: "Inbox", icon: Inbox },
{ label: "Team", icon: Users },
{ label: "Reports", icon: BarChart3 },
{ label: "Settings", icon: Settings },
];
function NavList() {
return (
<ul className="grid gap-0.5 p-3">
{nav.map(({ label, icon: Icon, active }) => (
<li key={label}>
<a
href={`#${label.toLowerCase()}`}
aria-current={active ? "page" : undefined}
className="flex h-9 items-center gap-2.5 rounded-lg px-2.5 text-sm font-medium text-muted-foreground outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-[current=page]:bg-accent aria-[current=page]:text-accent-foreground"
>
<Icon aria-hidden="true" className="size-4" />
{label}
</a>
</li>
))}
</ul>
);
}
export default function AppShellDemo() {
return (
<AppShell role="region" tabIndex={0} aria-label="Dashboard preview" className="outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 h-96 min-h-0 w-full max-w-4xl overflow-auto rounded-xl border shadow-sm">
<AppShellSkipLink />
<AppShellHeader>
<AppShellSidebarTrigger />
<span className="text-[15px] font-semibold tracking-tight">Acme</span>
<span className="ms-auto text-xs text-muted-foreground">Q3 planning</span>
</AppShellHeader>
<AppShellSidebar label="Workspace" className="h-[calc(24rem-var(--app-header-h))]">
<NavList />
</AppShellSidebar>
<AppShellMain>
<h2 className="text-xl font-semibold tracking-tight">Overview</h2>
<p className="mt-1 text-sm text-muted-foreground">Header and side panels stay put; the page scrolls in the middle.</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{["Open tasks", "Shipped this week", "Blocked", "Due soon"].map((t, i) => (
<div key={t} className="rounded-xl border bg-card p-4">
<p className="text-xs text-muted-foreground">{t}</p>
<p className="text-2xl font-semibold tabular-nums">{[24, 9, 2, 6][i]}</p>
</div>
))}
</div>
</AppShellMain>
<AppShellAside className="h-[calc(24rem-var(--app-header-h))]" label="Activity">
<p className="text-sm font-medium">Activity</p>
<ul className="mt-3 grid gap-3 text-sm text-muted-foreground">
<li>Ana closed 3 tasks</li>
<li>Kofi commented on Launch plan</li>
<li>Mei joined the team</li>
</ul>
</AppShellAside>
<AppShellFooter>Acme workspace · all systems normal</AppShellFooter>
</AppShell>
);
}Installation
$ pnpm dlx shadcn@latest add @ballmac/app-shellInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/sheet @ballmac/i18nCopy the source into your project.
components/ballmac/app-shell.tsx// Ballmac UI: App Shell. https://ui.ballmac.com/components/app-shell "use client"; import * as React from "react"; import { PanelLeft } from "lucide-react"; import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ballmac/sheet"; import { cn } from "@/lib/utils"; import { useMessages } from "@/lib/ballmac/i18n"; type AppShellContextValue = { sidebarOpen: boolean; setSidebarOpen: (open: boolean) => void }; const AppShellContext = React.createContext<AppShellContextValue | null>(null); function useAppShell() { const context = React.useContext(AppShellContext); if (!context) throw new Error("App shell parts must be used inside <AppShell>"); return context; } type AppShellProps = React.ComponentProps<"div"> & { /** Height of the header, used to offset sticky side panels. Any CSS length. */ headerHeight?: string; /** Width of the sidebar column from the `lg` breakpoint up. */ sidebarWidth?: string; /** Width of the aside column from the `xl` breakpoint up. */ asideWidth?: string; }; /** * The frame of an application page: skip link, sticky header, sidebar, main content, optional aside and footer. * The sidebar becomes a sheet below `lg`. Panels stick below the header and scroll on their own. */ function AppShell({ headerHeight = "3.5rem", sidebarWidth = "15rem", asideWidth = "18rem", className, style, children, ...props }: AppShellProps) { const [sidebarOpen, setSidebarOpen] = React.useState(false); const value = React.useMemo(() => ({ sidebarOpen, setSidebarOpen }), [sidebarOpen]); return ( <AppShellContext.Provider value={value}> <div data-slot="app-shell" style={ { "--app-header-h": headerHeight, "--app-sidebar-w": sidebarWidth, "--app-aside-w": asideWidth, ...style, } as React.CSSProperties } className={cn( "grid min-h-svh w-full grid-cols-1 grid-rows-[auto_1fr_auto] bg-background text-foreground", "lg:grid-cols-[var(--app-sidebar-w)_minmax(0,1fr)]", "has-[[data-slot=app-shell-aside]]:xl:grid-cols-[var(--app-sidebar-w)_minmax(0,1fr)_var(--app-aside-w)]", className, )} {...props} > {children} </div> </AppShellContext.Provider> ); } type AppShellSkipLinkProps = React.ComponentProps<"a">; /** Becomes visible on keyboard focus and jumps to the main content. */ function AppShellSkipLink({ className, children = "Skip to content", href = "#app-main", ...props }: AppShellSkipLinkProps) { return ( <a data-slot="app-shell-skip-link" href={href} className={cn( "sr-only z-50 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground focus:not-sr-only focus:fixed focus:top-2 focus:start-2 focus-visible:ring-[3px] focus-visible:ring-ring/50", className, )} {...props} > {children} </a> ); } type AppShellHeaderProps = React.ComponentProps<"header">; function AppShellHeader({ className, ...props }: AppShellHeaderProps) { return ( <header data-slot="app-shell-header" className={cn( "sticky top-0 z-30 flex h-(--app-header-h) items-center gap-3 border-b bg-background/85 px-4 backdrop-blur-md lg:col-span-full", className, )} {...props} /> ); } type AppShellSidebarTriggerProps = React.ComponentProps<"button">; /** Opens the sidebar sheet below `lg`. Hidden on wider screens. */ function AppShellSidebarTrigger({ className, onClick, ...props }: AppShellSidebarTriggerProps) { const msg = useMessages() const { sidebarOpen, setSidebarOpen } = useAppShell(); return ( <button type="button" data-slot="app-shell-sidebar-trigger" aria-label={msg("app-shell.openNavigation", "Open navigation")} aria-expanded={sidebarOpen} onClick={(event) => { onClick?.(event); setSidebarOpen(true); }} className={cn( "-ms-1 inline-flex size-9 items-center justify-center rounded-md outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 lg:hidden", className, )} {...props} > <PanelLeft aria-hidden="true" className="size-5 rtl:-scale-x-100" /> </button> ); } type AppShellSidebarProps = React.ComponentProps<"aside"> & { /** Accessible name of the landmark and the mobile sheet. */ label?: string; }; function AppShellSidebar({ label, className, children, ...props }: AppShellSidebarProps) { const msg = useMessages() label ??= msg("app-shell.label", "Sidebar") const { sidebarOpen, setSidebarOpen } = useAppShell(); return ( <> <aside data-slot="app-shell-sidebar" aria-label={label} className={cn( "sticky top-(--app-header-h) hidden h-[calc(100svh-var(--app-header-h))] flex-col overflow-y-auto border-e bg-card/50 lg:flex", className, )} {...props} > {children} </aside> <Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}> <SheetContent side="start" showCloseButton={false} className="w-72 gap-0 p-0 sm:w-72 lg:hidden"> <SheetTitle className="sr-only">{label}</SheetTitle> <SheetDescription className="sr-only">{msg("app-shell.navigation", "Navigation")}</SheetDescription> <div className="flex h-full flex-col overflow-y-auto" onClick={(e) => (e.target as HTMLElement).closest("a") && setSidebarOpen(false)}> {children} </div> </SheetContent> </Sheet> </> ); } type AppShellMainProps = React.ComponentProps<"main">; function AppShellMain({ className, ...props }: AppShellMainProps) { return ( <main id="app-main" tabIndex={-1} data-slot="app-shell-main" className={cn("min-w-0 p-4 outline-none sm:p-6 lg:p-8", className)} {...props} /> ); } type AppShellAsideProps = React.ComponentProps<"aside"> & { label?: string }; /** A right column shown from `xl` up (details, activity, table of contents). */ function AppShellAside({ label, className, ...props }: AppShellAsideProps) { const msg = useMessages() label ??= msg("app-shell.label2", "Details") return ( <aside data-slot="app-shell-aside" aria-label={label} className={cn( "sticky top-(--app-header-h) hidden h-[calc(100svh-var(--app-header-h))] overflow-y-auto border-s p-5 xl:block", className, )} {...props} /> ); } type AppShellFooterProps = React.ComponentProps<"footer">; function AppShellFooter({ className, ...props }: AppShellFooterProps) { return ( <footer data-slot="app-shell-footer" className={cn("border-t px-4 py-3 text-xs text-muted-foreground sm:px-6 lg:col-span-full", className)} {...props} /> ); } export { AppShell, AppShellSkipLink, AppShellHeader, AppShellSidebarTrigger, AppShellSidebar, AppShellMain, AppShellAside, AppShellFooter, useAppShell, type AppShellProps, type AppShellSkipLinkProps, type AppShellHeaderProps, type AppShellSidebarTriggerProps, type AppShellSidebarProps, type AppShellMainProps, type AppShellAsideProps, type AppShellFooterProps, };Update the import paths to match your project setup.
Usage
import { AppShell, AppShellSkipLink, AppShellHeader, AppShellSidebarTrigger, AppShellSidebar, AppShellMain, AppShellAside, AppShellFooter, useAppShell } from "@/components/ballmac/app-shell"The full example is in the Code tab above.
Examples
Compact shell
"use client";
import { Home, Settings } from "lucide-react";
import { AppShell, AppShellFooter, AppShellHeader, AppShellMain, AppShellSidebar, AppShellSidebarTrigger } from "@/components/ballmac/app-shell";
export default function AppShellStates() {
return (
<AppShell headerHeight="3rem" sidebarWidth="12rem" role="region" tabIndex={0} aria-label="Compact shell preview" className="outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 h-64 min-h-0 w-full max-w-2xl overflow-auto rounded-xl border">
<AppShellHeader>
<AppShellSidebarTrigger />
<span className="text-sm font-semibold">Compact shell</span>
</AppShellHeader>
<AppShellSidebar label="Sections" className="h-[calc(16rem-3rem)]">
<ul className="grid gap-0.5 p-2 text-sm">
<li className="flex items-center gap-2 rounded-md bg-accent px-2 py-1.5 font-medium"><Home aria-hidden="true" className="size-4" /> Home</li>
<li className="flex items-center gap-2 px-2 py-1.5 text-muted-foreground"><Settings aria-hidden="true" className="size-4" /> Settings</li>
</ul>
</AppShellSidebar>
<AppShellMain className="p-4 lg:p-5">
<p className="text-sm text-muted-foreground">Narrower header and sidebar through props. Below the lg breakpoint the sidebar opens from the menu button.</p>
</AppShellMain>
<AppShellFooter>Footer</AppShellFooter>
</AppShell>
);
}API reference
<AppShell>
| Prop | Type | Default |
|---|---|---|
headerHeightHeight of the header, used to offset sticky side panels. Any CSS length. | string | "3.5rem" |
sidebarWidthWidth of the sidebar column from the `lg` breakpoint up. | string | "15rem" |
asideWidthWidth of the aside column from the `xl` breakpoint up. | string | "18rem" |
<AppShellSidebar>
| Prop | Type | Default |
|---|---|---|
labelAccessible name of the landmark and the mobile sheet. | string | — |
<AppShellAside>
| Prop | Type | Default |
|---|---|---|
label | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab | The first stop is the skip link; it jumps to the main content |
| Screen readers | Header, sidebar, main, aside and footer are landmarks |
| Escape | Closes the mobile sidebar |
Use with AI
AppShell > AppShellSkipLink, AppShellHeader (with AppShellSidebarTrigger), AppShellSidebar, AppShellMain, AppShellAside, AppShellFooter. Sizes are CSS variables set by props. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI App Shell (@ballmac/app-shell) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Admin and dashboard pages
- Any page with a sidebar, a main column and optional details
Not for
- A collapsible icon-rail sidebar; use sidebar
- Marketing pages; use navbar
Registry JSON: https://ui.ballmac.com/r/app-shell.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
Pairs well with
Sidebar
An app sidebar with icon-rail collapse, floating and inset variants, grouped menus with badges and sub-items, a mobile sheet, tooltips when collapsed and a Cmd/Ctrl+B shortcut.
Navbar
A responsive site header that gains a border and shadow on scroll, can hide while scrolling down, and turns its links into a sheet menu on small screens.
Sheet
A modal panel that slides from any edge, with header, scrollable body and footer parts, a grab handle on bottom sheets, and safe-area padding.
Aspect Ratio
A CSS-native ratio frame that reserves space for media and safely handles invalid ratio values.