- Accordion
- AppShell
- Autocomplete
- Badge
- Blobatar
- BoringAvatar
- Button
- Calendar
- Checkbox
- DataGrid
- Date Picker
- Date Range Picker
- DateTime
- Dialog
- Drawer
- Dropdown
- Empty
- Error State
- Frame
- Input
- Input Amount
- Input Group
- Input OTP
- Input Password
- Input Phone
- Input Search
- Input Secret
- Loading
- PageContainer
- PageHeader
- Popover
- Select
- StatsCard
- Switch
- Tabs
- TextArea
- Toast
- Tooltip
Installation
Initialize shadcn in your React project, then install this component from the Vandor UI registry:
$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/app-shell.json
Dependencies
Installed automatically by the registry CLI. For manual setup:
- cn@^0.4.0
- lucide-react
- motion@^12.38.0
$ pnpm add cn@^0.4.0 lucide-react motion@^12.38.0Usage
"use client";
import { useState } from "react";
import { AppShell } from "@/components/ui/app-shell";
export default function WorkspaceLayout() {
const [active, setActive] = useState("overview");
return (
<AppShell
brand={{ name: "Acme" }}
navigation={[
{
id: "overview",
label: "Overview",
active: active === "overview",
onClick: () => setActive("overview"),
},
{
id: "projects",
label: "Projects",
active: active === "projects",
onClick: () => setActive("projects"),
},
]}
>
<h1>{active === "overview" ? "Overview" : "Projects"}</h1>
</AppShell>
);
}AppShell owns the layout and navigation presentation. Your application owns routes, active destinations, resource data, and search UI. These previews demonstrate local state, not real routing or authentication. AppShell uses states and runnable examples rather than a props playground.
showHeader defaults to true. On desktop, the sidebar toggle sits beside the brand or custom workspace header, not in the content header. The collapsed rail keeps an expand control in its identity area. Set showHeader to false for a headerless shell; the main surface remains edge-to-edge so the consumer can choose page spacing. PageContainer now defaults to p-4 sm:p-6 lg:p-12 (16/24/48px), so add it only where that inset is wanted. This is a breaking spacing change from the previous shell padding and lg:p-8 page container.
An optional user config renders the built-in account menu. It is presentation only: the consumer supplies the identity and DropdownEntry[] actions. A provided sidebarFooter takes precedence, including null. Async action and cancellation behavior follows Dropdown's closeOnSelect contract. Use labels to localize shell navigation controls.
import { AppShell } from "@/components/ui/app-shell";
import { PageContainer } from "@/components/ui/page-container";
export function HeaderlessLayout() {
return (
<AppShell brand={{ name: "Acme" }} navigation={[]} showHeader={false}>
<PageContainer>Page content</PageContainer>
</AppShell>
);
}Install PageContainer separately when using this composition. Omit it for full-bleed content. Headerless mode hides header, actions, and the search button without relocating them; the search shortcut still runs when onSearch is supplied. The mobile brand tab and floating menu honor safe-area insets, with initial content clearance. When embedding a headerless shell in a preview, give the shell relative isolate [contain:layout] to contain these fixed controls without changing the full-page layout. Its drawer opens from the right; header mode opens from the left. The sidebar uses sidebar/sidebar-foreground tokens.
Built-in identity and localized controls
Account menu
No account action selected.
"use client";
import { useState } from "react";
import { AppShell } from "@/components/ui/app-shell";
import { PageContainer } from "@/components/ui/page-container";
export const AppShellUserDemo = () => {
const [status, setStatus] = useState("No account action selected.");
return (
<AppShell
brand={{ name: "Acme" }}
navigation={[]}
showHeader={false}
className="relative isolate min-h-80 w-full [contain:layout] [&_[data-slot=app-shell-sidebar]]:h-80"
labels={{
closeNavigation: "Tutup navigasi",
collapseSidebar: "Ciutkan sidebar",
expandSidebar: "Perluas sidebar",
openNavigation: "Buka navigasi",
}}
user={{
description: "avery@example.test",
items: [
{
id: "profile",
label: "Profile",
link: <a href="#account-profile">Profile</a>,
},
{
id: "account",
items: [
{
closeOnSelect: "success",
id: "save",
label: "Save locally",
onSelect: async () => {
await Promise.resolve();
setStatus("Local save completed.");
},
},
{
closeOnSelect: "success",
id: "retry",
label: "Simulate rejection",
onSelect: () =>
Promise.reject(new Error("Local demonstration only")),
onSelectError: () =>
setStatus("Rejected action: navigation remains available."),
},
],
label: "Local actions",
type: "group",
},
],
menuLabel: "Menu akun Avery",
name: "Avery Cole",
}}
>
<PageContainer>
<h2 id="account-profile" className="text-lg font-semibold">
Account menu
</h2>
<p role="status" className="mt-2 text-sm text-muted-foreground">
{status}
</p>
</PageContainer>
</AppShell>
);
};user requires name and items: readonly DropdownEntry[]; optional description, avatar, pending, and menuLabel configure identity. Missing avatar uses initials. pending disables the trigger and announces busy state. The collapsed trigger has an identity tooltip. Groups, submenus, real links, cancellation, overlays and error callbacks use Dropdown's existing contract. No authentication is implemented.
Accepted synchronous actions close mobile navigation. Async actions keep the drawer owner mounted until fulfillment, even when Dropdown closes its menu immediately by default. Rejected/thrown actions, canceled events, closeOnSelect: false, overlay activation and menu-only dismissal do not close navigation. closeOnSelect: "success" also keeps the menu open while pending. A late fulfillment from a dismissed/reopened menu does not close the newer navigation session.
States
Expanded
Overview
Select a destination, collapse the sidebar, or open navigation on mobile.
Local demo. No application routes are changed.
"use client";
import { FolderIcon, LayoutGridIcon, SettingsIcon } from "lucide-react";
import { useState } from "react";
import { AppShell } from "@/components/ui/app-shell";
import { PageContainer } from "@/components/ui/page-container";
// Only the fixture is bounded; a real application uses AppShell's viewport layout.
export const AppShellDemo = ({
defaultCollapsed = false,
}: {
defaultCollapsed?: boolean;
}) => {
const [active, setActive] = useState("overview");
const destinations = [
{ icon: <LayoutGridIcon />, id: "overview", label: "Overview" },
{ icon: <FolderIcon />, id: "projects", label: "Projects" },
{ icon: <SettingsIcon />, id: "settings", label: "Settings" },
];
const title = destinations.find((item) => item.id === active)?.label;
return (
<AppShell
brand={{ icon: <LayoutGridIcon />, name: "Acme" }}
navigation={destinations.map((item) => ({
...item,
active: item.id === active,
onClick: () => setActive(item.id),
}))}
defaultCollapsed={defaultCollapsed}
header={<span className="truncate text-sm font-medium">Workspace</span>}
className="h-80 min-h-0 w-full [&_[data-slot=app-shell-sidebar]]:h-full [&_[data-slot=app-shell-header]]:static [&_[data-slot=app-shell-main]]:overflow-y-auto"
>
<PageContainer>
<h2 className="text-lg font-semibold">{title}</h2>
<p className="mt-2 text-sm text-muted-foreground">
Select a destination, collapse the sidebar, or open navigation on
mobile.
</p>
<p className="mt-6 text-xs text-muted-foreground">
Local demo. No application routes are changed.
</p>
</PageContainer>
</AppShell>
);
};
export const AppShellCollapsedDemo = () => <AppShellDemo defaultCollapsed />;Desktop navigation is 15rem wide. Its middle section scrolls independently while the sidebar header and footer stay in place. The top header is sticky in a real application.
Collapsed icon rail
Overview
Select a destination, collapse the sidebar, or open navigation on mobile.
Local demo. No application routes are changed.
"use client";
import { FolderIcon, LayoutGridIcon, SettingsIcon } from "lucide-react";
import { useState } from "react";
import { AppShell } from "@/components/ui/app-shell";
import { PageContainer } from "@/components/ui/page-container";
// Only the fixture is bounded; a real application uses AppShell's viewport layout.
export const AppShellDemo = ({
defaultCollapsed = false,
}: {
defaultCollapsed?: boolean;
}) => {
const [active, setActive] = useState("overview");
const destinations = [
{ icon: <LayoutGridIcon />, id: "overview", label: "Overview" },
{ icon: <FolderIcon />, id: "projects", label: "Projects" },
{ icon: <SettingsIcon />, id: "settings", label: "Settings" },
];
const title = destinations.find((item) => item.id === active)?.label;
return (
<AppShell
brand={{ icon: <LayoutGridIcon />, name: "Acme" }}
navigation={destinations.map((item) => ({
...item,
active: item.id === active,
onClick: () => setActive(item.id),
}))}
defaultCollapsed={defaultCollapsed}
header={<span className="truncate text-sm font-medium">Workspace</span>}
className="h-80 min-h-0 w-full [&_[data-slot=app-shell-sidebar]]:h-full [&_[data-slot=app-shell-header]]:static [&_[data-slot=app-shell-main]]:overflow-y-auto"
>
<PageContainer>
<h2 className="text-lg font-semibold">{title}</h2>
<p className="mt-2 text-sm text-muted-foreground">
Select a destination, collapse the sidebar, or open navigation on
mobile.
</p>
<p className="mt-6 text-xs text-muted-foreground">
Local demo. No application routes are changed.
</p>
</PageContainer>
</AppShell>
);
};
export const AppShellCollapsedDemo = () => <AppShellDemo defaultCollapsed />;The desktop toggle animates the sidebar to 4rem and fades the labels. Standard items retain accessible names and show tooltips in the icon rail. An item without an icon uses the first character of its label. Custom controls must provide their own compact presentation and accessible names.
Mobile drawer
Below the default Tailwind md breakpoint (768px), the desktop sidebar is hidden and the header's menu button opens a left-side Drawer. Try either preview at a mobile viewport. Navigation is always expanded inside the drawer, even when the desktop icon rail is collapsed. Standard destination selection closes the drawer; Escape and the close button also dismiss it. Changing to a desktop viewport closes the mobile drawer.
The preview height overrides are only for embedding examples. Breakpoints follow the viewport, not the preview width; a narrow preview on a desktop viewport does not automatically activate mobile navigation.
Examples
Full-bleed content
Full-bleed canvas
This surface reaches the main content edges. No PageContainer or shell page padding is applied; the canvas owns its internal spacing.
"use client";
import { AppShell } from "@/components/ui/app-shell";
export const AppShellFullBleedDemo = () => (
<AppShell
brand={{ name: "Acme" }}
navigation={[{ active: true, id: "canvas", label: "Canvas" }]}
showHeader={false}
className="relative isolate h-96 min-h-0 w-full [contain:layout] [&_[data-slot=app-shell-sidebar]]:h-full"
>
<section
aria-label="Full-bleed canvas"
className="min-h-96 w-full bg-muted"
>
<h2 className="p-4 text-lg font-semibold">Full-bleed canvas</h2>
<p className="px-4 text-sm text-muted-foreground">
This surface reaches the main content edges. No PageContainer or shell
page padding is applied; the canvas owns its internal spacing.
</p>
</section>
</AppShell>
);The canvas omits PageContainer and owns its own internal spacing. Other page-style examples use PageContainer exactly once.
Workspace and account controls
Overview
Local workspace fixture
"use client";
import {
CheckIcon,
ChevronsUpDownIcon,
FolderIcon,
LayoutGridIcon,
UserRoundIcon,
} from "lucide-react";
import { useState } from "react";
import { AppShell } from "@/components/ui/app-shell";
import { Button } from "@/components/ui/button";
import { Dropdown } from "@/components/ui/dropdown";
import { PageContainer } from "@/components/ui/page-container";
export const AppShellWorkspaceDemo = () => {
const [workspace, setWorkspace] = useState("Acme");
const [active, setActive] = useState("Overview");
const [notice, setNotice] = useState("Local workspace fixture");
return (
<AppShell
brand={{ name: workspace }}
sidebarHeader={({ collapsed, closeNavigation }) => (
<Dropdown
contentProps={{
align: "start",
side: collapsed ? "right" : "bottom",
}}
trigger={
<Button
variant="ghost"
className="h-12 w-full min-w-0 justify-start overflow-hidden px-2"
aria-label={`Switch workspace: ${workspace}`}
>
<span
aria-hidden="true"
className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted"
>
{workspace[0]}
</span>
{!collapsed && (
<>
<span className="min-w-0 flex-1 truncate text-left">
{workspace}
</span>
<ChevronsUpDownIcon aria-hidden="true" />
</>
)}
</Button>
}
items={[
{
id: "workspaces",
items: ["Acme", "Orbit"].map((name) => ({
icon: name === workspace ? CheckIcon : undefined,
id: name,
label: name,
onSelect: () => {
setWorkspace(name);
setNotice(`${name} selected`);
closeNavigation();
},
})),
label: "Workspaces",
type: "group",
},
]}
/>
)}
sidebarFooter={({ collapsed }) => (
<Dropdown
contentProps={{ align: "end", side: "right" }}
trigger={
<Button
variant="ghost"
className="h-12 w-full justify-start overflow-hidden px-3"
aria-label="Account menu"
>
<UserRoundIcon aria-hidden="true" />
{!collapsed && <span className="truncate">Avery Cole</span>}
</Button>
}
items={[
{
id: "profile",
label: "Profile",
onSelect: () => setNotice("Profile selected"),
},
{
id: "sign-out",
label: "Sign out",
onSelect: () =>
setNotice("Demo sign out selected. No session was changed."),
},
]}
/>
)}
navigation={[
{
active: active === "Overview",
icon: <LayoutGridIcon />,
id: "overview",
label: "Overview",
onClick: () => setActive("Overview"),
},
{
active: active === "Projects",
icon: <FolderIcon />,
id: "projects",
label: "Projects",
onClick: () => setActive("Projects"),
},
]}
header={<span className="truncate text-sm font-medium">{workspace}</span>}
className="h-96 min-h-0 w-full [&_[data-slot=app-shell-sidebar]]:h-full [&_[data-slot=app-shell-header]]:static [&_[data-slot=app-shell-main]]:overflow-y-auto"
>
<PageContainer>
<h2 className="text-lg font-semibold">{active}</h2>
<p role="status" className="mt-2 text-sm text-muted-foreground">
{notice}
</p>
</PageContainer>
</AppShell>
);
};Dropdown is included in AppShell's dependency closure. sidebarHeader replaces the default brand row; both it and sidebarFooter receive { collapsed, closeNavigation }. Both receive collapsed: false on mobile. Slots are rendered separately on desktop and mobile: keep shared selection state in the consuming application, not inside a slot-local component.
Hierarchy and controlled collapse
Agents
Open an agent to replace the middle navigation panel.
"use client";
import {
ArrowLeftIcon,
BotIcon,
CheckIcon,
ChevronsUpDownIcon,
HistoryIcon,
LayoutGridIcon,
SlidersHorizontalIcon,
} from "lucide-react";
import { useState } from "react";
import { AppShell } from "@/components/ui/app-shell";
import type { AppShellNavigationItem } from "@/components/ui/app-shell";
import { Button } from "@/components/ui/button";
import { Dropdown } from "@/components/ui/dropdown";
import { PageContainer } from "@/components/ui/page-container";
export const AppShellHierarchyDemo = () => {
const [context, setContext] = useState<"workspace" | "agent">("workspace");
const [agent, setAgent] = useState("Customer Support");
const [section, setSection] = useState("Profile");
const [collapsed, setCollapsed] = useState(false);
const agents = ["Customer Support", "Research Assistant"];
const openAgent = (name: string) => {
setAgent(name);
setSection("Profile");
setContext("agent");
};
const navigation: AppShellNavigationItem[] =
context === "workspace"
? [
{
icon: <LayoutGridIcon />,
id: "overview",
label: "Overview",
onClick: () => setSection("Overview"),
},
{
active: section !== "Overview",
icon: <BotIcon />,
id: "agents",
label: "Agents",
onClick: () => setSection("Agents"),
},
]
: [
{
dividerAfter: true,
icon: <ArrowLeftIcon />,
id: "back",
label: "All agents",
onClick: () => {
setContext("workspace");
setSection("Agents");
},
},
...["Profile", "Configure", "Versions"].map((label) => ({
active: section === label,
icon:
label === "Versions" ? (
<HistoryIcon />
) : (
<SlidersHorizontalIcon />
),
id: label,
label,
onClick: () => setSection(label),
})),
];
return (
<AppShell
brand={{ icon: <LayoutGridIcon />, name: "Acme" }}
sidebarHeader={
context === "agent"
? ({ collapsed: compact, closeNavigation }) => (
<Dropdown
contentProps={{ align: "start", side: "right" }}
trigger={
<Button
variant="ghost"
className="h-12 w-full min-w-0 justify-start overflow-hidden px-3"
aria-label={`Switch agent: ${agent}`}
>
<BotIcon aria-hidden="true" />
{!compact && (
<>
<span className="min-w-0 flex-1 truncate text-left">
{agent}
</span>
<ChevronsUpDownIcon aria-hidden="true" />
</>
)}
</Button>
}
items={[
{
id: "agents",
items: agents.map((name) => ({
icon: name === agent ? CheckIcon : undefined,
id: name,
label: name,
onSelect: () => {
openAgent(name);
closeNavigation();
},
})),
label: "Agents",
type: "group",
},
]}
/>
)
: undefined
}
navigation={navigation}
navigationContext={{
depth: context === "workspace" ? 0 : 1,
key: context,
}}
navigationLabel={
context === "workspace" ? "Workspace navigation" : "Agent navigation"
}
collapsed={collapsed}
onCollapsedChange={setCollapsed}
header={
<span className="truncate text-sm font-medium">
{context === "workspace" ? "Workspace" : agent}
</span>
}
className="h-[28rem] min-h-0 w-full [&_[data-slot=app-shell-sidebar]]:h-full [&_[data-slot=app-shell-header]]:static [&_[data-slot=app-shell-main]]:overflow-y-auto"
>
<PageContainer>
<h2 className="text-lg font-semibold">
{context === "workspace" ? "Agents" : section}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
{context === "workspace"
? "Open an agent to replace the middle navigation panel."
: "Return with All agents. Switching agents keeps the same navigation panel."}
</p>
{context === "workspace" && (
<div className="mt-6 flex flex-col items-start gap-3">
{agents.map((name) => (
<Button
key={name}
variant="outline"
className="max-w-full whitespace-normal text-left"
onClick={() => openAgent(name)}
>
<BotIcon aria-hidden="true" />
{name}
</Button>
))}
</div>
)}
{context === "agent" && (
<p className="mt-6 text-sm text-muted-foreground">
{agent} / {section}
</p>
)}
</PageContainer>
</AppShell>
);
};Open an agent to replace the middle navigation with a return action and detail destinations. The agent switcher uses the sidebar header slot, outside the transitioning panel, rather than returning a composite Dropdown from renderControl. Return with All agents. Dropdown is included in the shell closure; the example also installs PageContainer and controls collapsed through onCollapsedChange.
navigationContext={{ key, depth }} is presentation metadata, not a router or navigation stack. Keep a stable key for each panel and a consistent depth for that key. Changing the key triggers a 220ms slide and fade; increasing depth moves forward and decreasing depth moves back. A different key at the same depth moves forward. Active-item changes and switching resources within the same panel keep the key unchanged and do not replay the hierarchy transition. Without navigationContext, navigation updates without panel transitions.
The hierarchy can have more than two levels. Your application supplies each panel's items, return actions, and state. AppShell does not fetch resources, remember history, or manage browser back navigation.
Navigation item contract
| Property | Behavior |
|---|---|
id, label | Required unique identity and accessible label for a standard item. |
icon | Optional decorative React node; the first label character is the fallback. |
href | Uses a native anchor unless disabled. Use renderControl for router-specific Link components. |
onClick | Called synchronously on selection; mobile navigation closes immediately, without awaiting async work. |
active | Adds selected styling and aria-current="page"; the application determines the active item. |
disabled | Standard item becomes a disabled button, including when it has an href. |
dividerAfter | Adds a separator after a standard or custom item. |
renderControl | Returns one ReactElement control receiving { collapsed, closeNavigation }. The shell composes it as a tooltip trigger on collapsed desktop only, without a button wrapper. |
With renderControl, the consumer owns the label, icon, active/disabled behavior, click callback, and mobile closing. The item-level href, onClick, active, and disabled do not apply automatically. id and dividerAfter still apply. Use closeNavigation() after a custom selection when dismissal is appropriate.
The returned native element or router Link must forward received props, handlers and ref to one focusable root. Fragments, multiple roots and components dropping these props are unsupported. This replaces the previous arbitrary-node contract. Preserve native href/modifier semantics and compose caller cancellation when deciding whether to close navigation. Tooltips use hover and keyboard focus; expanded/mobile navigation has no redundant tooltip.
Optional search
Supplying onSearch enables the search button and a document-level Cmd/Ctrl+K callback. AppShell does not render a search dialog or query results. With no callback, there is no search UI or global listener. Prevented, repeated, and Alt-modified key events are ignored; the shortcut also runs while editing an input. Mount only one search-enabled shell per document to avoid duplicate handlers.
Consumer setup and optional stories
Requires React 19, Tailwind CSS 4, motion, cn, and lucide-react, together with the Vandor Button, Drawer, Dropdown, and Tooltip registry items. Their transitive registry/package dependencies are installed by the CLI. Use your global Tailwind stylesheet and semantic background, foreground, sidebar, sidebar-foreground, border, muted, muted-foreground, accent, accent-foreground, ring, and underlying Button/Drawer theme tokens. Keep the standard Tailwind md breakpoint at 768px to match the drawer's desktop media query.
AppShell is a client component and can be imported from a Next.js Server Component. Functions such as navigation callbacks and render slots must be supplied inside a client boundary, not passed from a Server Component as serialized props. Installation uses the configured UI alias in components.json, with no fixed target directory. Review CLI overwrite prompts for shared dependencies before replacing customized primitives.
Copying the Source block alone is insufficient: AppShell requires its colocated app-shell-user.tsx helper plus Button, Drawer, Dropdown and Tooltip. Prefer CLI installation; for manual setup install these Vandor items and declared packages first, then copy both shell modules into the same UI directory. Examples are not distributed with the component.
Install AppShell first, then optionally install its portable stories:
$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/app-shell-stories.json
Use your existing @storybook/react with matching Storybook package versions (type/composition checked with 10.6.1), discover **/*.stories.tsx in the configured UI directory, and load the global Tailwind/theme stylesheet. The stories import colocated AppShell and Button. No additional providers or addons are required; the stories-only item does not reinstall core files, force-install Storybook packages, or modify Storybook configuration. Use the consumer's viewport and reduced-motion settings for those stories. This project has no Storybook server, so Storybook UI is not verified here.
Behavior & Accessibility
The shell renders a sidebar aside, a named navigation nav, a top header, and one main. Do not put another main in children. The skip link targets the main content, and the desktop toggle exposes aria-expanded and aria-controls. Navigation supports normal Tab and native link/button keyboard behavior, not treeview arrow-key navigation.
Desktop collapsed state is uncontrolled by default; defaultCollapsed is read only on mount. With collapsed, your application must update that prop in onCollapsedChange. The callback reports a toggle request synchronously, not animation completion. Mobile open state is separate and internal. Neither state is persisted automatically.
Hierarchy transitions use Motion's AnimatePresence. Outgoing panels are inert and hidden from assistive technology. If focus is inside a retiring panel, it moves to the incoming panel; otherwise the shell does not steal focus. The drawer provides modal focus management and opener focus restoration. Custom dropdowns/router links must preserve their own focus and dismissal contracts.
Sidebar width and label transitions are 220ms and 140ms respectively. Hierarchy panels travel 28px with a 220ms slide/fade. All respect prefers-reduced-motion; the existing Drawer handles its own reduced-motion styles. AppShell adds no page-enter animation, search implementation, audio, or haptics.
Standard navigation labels truncate visually but remain available through accessible names and collapsed tooltips. Keep header actions compact and give arbitrary slot content its own responsive layout. The shell accepts only its documented props; it does not forward native root attributes or refs.
Props
| Prop | Type / Description | Default |
|---|---|---|
| actions | ReactNodeTrailing header actions. Consumers provide accessible names and responsive layouts. | undefined |
| brand | { name: string; icon?: ReactNode; href?: string }Brand shown in the default desktop sidebar header. name also labels the mobile drawer when sidebarHeader is absent. href turns the desktop brand into a link. | Required |
| children | ReactNodeContent inside the shell's main landmark. Do not nest another main element. | Required |
| className | stringMerged classes on the shell root. Default is a viewport-height application layout. | undefined |
| collapsed | booleanControlled desktop collapse state. The caller must update it in response to onCollapsedChange. | undefined |
| contentClassName | stringMerged classes on the main content element, not the navigation or top header. | undefined |
| defaultCollapsed | booleanInitial uncontrolled desktop collapse state. Later changes to this prop do not reset the state. | false |
| header | ReactNodeContent after the navigation trigger and optional search button in the top header. | undefined |
| labels | { sidebar?: string; skipToContent?: string; openNavigation?: string; closeNavigation?: string; expandSidebar?: string; collapseSidebar?: string }Accessible shell labels, independent of navigationLabel and searchLabel. | English labels |
| navigation | readonly AppShellNavigationItem[]Ordered navigation. Each item requires a unique id and label. Optional icon, href, onClick, active, disabled, dividerAfter and renderControl configure the item. An empty array is supported. | Required |
| navigationContext | { key: string; depth: number }Optional transition identity. A key change replaces the middle navigation panel. Increasing depth slides in, decreasing depth slides back; same depth slides in. Keep key stable for active-menu and same-panel resource changes. | undefined |
| navigationLabel | stringAccessible name of navigation; also the mobile drawer title when a custom sidebarHeader is supplied. | "Main navigation" |
| onCollapsedChange | (collapsed: boolean) => voidCalled synchronously with the requested next state when the desktop toggle is activated. Not called for initial state or external prop updates. | undefined |
| onSearch | () => voidEnables a search button and document-level Cmd/Ctrl+K callback. No search dialog or shortcut listener exists without it. | undefined |
| searchLabel | stringVisible search-button label. Only used with onSearch. | "Search workspace" |
| showHeader | booleanShow the topbar. Headerless mode retains sidebar controls and a right-side mobile drawer; header/actions/search UI is hidden, not relocated. | true |
| sidebarFooter | ReactNode | ((state: AppShellNavigationState) => ReactNode)Persistent footer below the scrollable navigation. Receives collapsed and closeNavigation; mobile receives collapsed=false. Overrides user, including an explicit null. | undefined |
| sidebarHeader | ReactNode | ((state: AppShellNavigationState) => ReactNode)Replaces the brand row. Receives collapsed and closeNavigation. Mobile receives collapsed=false; closeNavigation closes only the mobile drawer. | undefined |
| user | AppShellUserOptional name, description, avatar, readonly DropdownEntry[] items, pending and menuLabel. Custom sidebarFooter (including null) overrides this identity menu. Async drawer closure waits for accepted fulfillment; rejected and canceled actions retain navigation. | undefined |
AppShell uses a deliberately restricted props API; it does not forward arbitrary native attributes, refs or event handlers. Rendered slots and renderControl own their elements and native props. Navigation items with href use links unless disabled; without href they use buttons.
Source
"use client";
// Adapted from Universe's AppShell, originally inspired by 7ovr's
// app-shell-3 icon-rail layout (free blocks: MIT-0).
// https://7ovr.com/r/app-shell-3.json
// Application data, routing and resource controls remain consumer-owned.
import { cn } from "cn";
import {
MenuIcon,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
SearchIcon,
} from "lucide-react";
import {
AnimatePresence,
motion,
useIsPresent,
useReducedMotion,
} from "motion/react";
import { useEffect, useId, useRef, useState } from "react";
import type { ReactElement, ReactNode } from "react";
import { AppShellUserFooter } from "./app-shell-user";
import type { AppShellUser } from "./app-shell-user";
import { Button } from "./button";
import { Drawer, DrawerBody } from "./drawer";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "./tooltip";
export type { AppShellUser } from "./app-shell-user";
export interface AppShellNavigationState {
collapsed: boolean;
closeNavigation: () => void;
}
export interface AppShellNavigationItem {
id: string;
label: string;
icon?: ReactNode;
href?: string;
onClick?: () => void;
active?: boolean;
disabled?: boolean;
/** Separates this item from the following navigation. */
dividerAfter?: boolean;
/** Replaces the standard item. The caller owns semantics and disabled state. */
renderControl?: (state: AppShellNavigationState) => ReactElement;
}
export interface AppShellProps {
brand: { name: string; icon?: ReactNode; href?: string };
navigation: readonly AppShellNavigationItem[];
children: ReactNode;
header?: ReactNode;
showHeader?: boolean;
actions?: ReactNode;
sidebarHeader?: ReactNode | ((state: AppShellNavigationState) => ReactNode);
sidebarFooter?: ReactNode | ((state: AppShellNavigationState) => ReactNode);
user?: AppShellUser;
/** Enables the search button and Cmd/Ctrl+K listener only when supplied. */
onSearch?: () => void;
searchLabel?: string;
defaultCollapsed?: boolean;
collapsed?: boolean;
onCollapsedChange?: (collapsed: boolean) => void;
navigationLabel?: string;
labels?: {
sidebar?: string;
skipToContent?: string;
openNavigation?: string;
closeNavigation?: string;
expandSidebar?: string;
collapseSidebar?: string;
};
/** Change key only for a new panel, not active items or same-level resources. */
navigationContext?: { key: string; depth: number };
className?: string;
contentClassName?: string;
}
const NavigationPanel = ({
children,
direction,
reduceMotion,
}: {
children: ReactNode;
direction: number;
reduceMotion: boolean;
}) => {
const present = useIsPresent();
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
// Never leave keyboard focus in an outgoing, inert navigation panel.
if (!present && ref.current?.contains(document.activeElement)) {
ref.current.parentElement
?.querySelector<HTMLElement>("[data-navigation-panel]:not([inert])")
?.focus();
}
}, [present]);
return (
<motion.div
ref={ref}
data-navigation-panel=""
data-navigation-direction={direction}
tabIndex={-1}
inert={!present}
aria-hidden={!present || undefined}
custom={direction}
variants={{
enter: (travel: number) => ({
opacity: reduceMotion ? 1 : 0,
x: reduceMotion ? 0 : travel * 28,
}),
exit: (travel: number) => ({
opacity: reduceMotion ? 1 : 0,
x: reduceMotion ? 0 : travel * -28,
}),
visible: { opacity: 1, x: 0 },
}}
initial="enter"
animate="visible"
exit="exit"
transition={{
duration: reduceMotion ? 0 : 0.22,
ease: [0.22, 1, 0.36, 1],
}}
className="col-start-1 row-start-1 min-w-0 self-start outline-none"
>
{children}
</motion.div>
);
};
const useNavigationDirection = (
navigationContext: AppShellProps["navigationContext"]
) => {
const [previousContext, setPreviousContext] = useState(navigationContext);
const [direction, setDirection] = useState(1);
if (navigationContext?.key !== previousContext?.key) {
setPreviousContext(navigationContext);
setDirection(
(navigationContext?.depth ?? 0) >= (previousContext?.depth ?? 0) ? 1 : -1
);
}
return direction;
};
const useShellTransitions = () => {
const reduceMotion = useReducedMotion();
return {
labelTransition: {
duration: reduceMotion ? 0 : 0.14,
ease: "easeOut" as const,
},
reduceMotion,
sidebarTransition: {
duration: reduceMotion ? 0 : 0.22,
ease: [0.22, 1, 0.36, 1] as const,
},
};
};
export const AppShell = ({
brand,
navigation,
children,
header,
showHeader = true,
actions,
sidebarHeader,
sidebarFooter,
user,
onSearch,
searchLabel = "Search workspace",
defaultCollapsed = false,
collapsed: controlledCollapsed,
onCollapsedChange,
navigationLabel = "Main navigation",
labels,
navigationContext,
className,
contentClassName,
}: AppShellProps) => {
const [internalCollapsed, setInternalCollapsed] = useState(defaultCollapsed);
const [mobileOpen, setMobileOpen] = useState(false);
const collapsed = controlledCollapsed ?? internalCollapsed;
const { reduceMotion, sidebarTransition, labelTransition } =
useShellTransitions();
const navigationDirection = useNavigationDirection(navigationContext);
// Animate real width as in Universe: FLIP scaling distorts nested scroll areas.
const contentId = useId();
const sidebarId = useId();
const closeNavigation = () => setMobileOpen(false);
useEffect(() => {
if (!onSearch) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.repeat || event.altKey) {
return;
}
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
onSearch();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onSearch]);
useEffect(() => {
const media = window.matchMedia("(min-width: 768px)");
const closeOnDesktop = () => {
if (media.matches) {
setMobileOpen(false);
}
};
media.addEventListener("change", closeOnDesktop);
return () => media.removeEventListener("change", closeOnDesktop);
}, []);
const renderNavigation = (compact: boolean, mobile = false) => (
<nav aria-label={navigationLabel} className="flex flex-col gap-1 p-2">
{navigation.map((item) => {
const label = (
<>
<span
aria-hidden="true"
className="flex size-5 shrink-0 items-center justify-center"
>
{item.icon ?? item.label.slice(0, 1)}
</span>
<motion.span
initial={false}
animate={{ opacity: compact ? 0 : 1 }}
transition={labelTransition}
aria-hidden={compact || undefined}
className="min-w-0 truncate"
>
{item.label}
</motion.span>
</>
);
const props = {
"aria-current": item.active ? ("page" as const) : undefined,
"aria-label": item.label,
className: cn(
"h-10 w-full justify-start gap-3 overflow-hidden px-3",
item.active && "bg-background text-foreground"
),
onClick: () => {
item.onClick?.();
if (mobile) {
closeNavigation();
}
},
};
const control =
item.href && !item.disabled ? (
<Button
variant="ghost"
nativeButton={false}
render={<a href={item.href} aria-label={item.label} />}
{...props}
>
{label}
</Button>
) : (
<Button variant="ghost" disabled={item.disabled} {...props}>
{label}
</Button>
);
return (
<div key={item.id}>
{item.renderControl ? (
<Tooltip>
<TooltipTrigger
disabled={!compact || mobile}
render={item.renderControl({
closeNavigation,
collapsed: compact,
})}
/>
<TooltipContent side="right">{item.label}</TooltipContent>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger
disabled={!compact || mobile}
render={control}
/>
<TooltipContent side="right">{item.label}</TooltipContent>
</Tooltip>
)}
{item.dividerAfter && (
<div
data-navigation-separator={item.id}
role="separator"
className="my-2 border-b border-border"
/>
)}
</div>
);
})}
</nav>
);
const renderContextNavigation = (compact: boolean, mobile = false) =>
navigationContext ? (
<div className="grid min-w-0 overflow-x-clip">
<AnimatePresence initial={false} custom={navigationDirection}>
<NavigationPanel
key={navigationContext.key}
direction={navigationDirection}
reduceMotion={!!reduceMotion}
>
{renderNavigation(compact, mobile)}
</NavigationPanel>
</AnimatePresence>
</div>
) : (
renderNavigation(compact, mobile)
);
const renderBrand = (compact: boolean) => {
const content = (
<>
<span
aria-hidden="true"
className="flex size-6 shrink-0 items-center justify-center font-semibold"
>
{brand.icon ?? brand.name.slice(0, 1)}
</span>
<motion.span
initial={false}
animate={{ opacity: compact ? 0 : 1 }}
transition={labelTransition}
aria-hidden={compact || undefined}
className={cn("min-w-0 truncate font-semibold", compact && "sr-only")}
>
{brand.name}
</motion.span>
{compact && <span className="sr-only">{brand.name}</span>}
</>
);
const styles = cn(
"flex h-full min-w-0 flex-1 items-center gap-3 overflow-hidden px-3",
compact && "h-10 w-full flex-none justify-center gap-0 px-0"
);
return brand.href ? (
<a
href={brand.href}
aria-label={brand.name}
className={cn(
styles,
"focus-visible:outline-2 focus-visible:outline-ring"
)}
>
{content}
</a>
) : (
<div className={styles}>{content}</div>
);
};
const renderSidebarHeader = (compact: boolean) =>
sidebarHeader ? (
<div className="flex min-w-0 flex-1 items-center">
{typeof sidebarHeader === "function"
? sidebarHeader({ closeNavigation, collapsed: compact })
: sidebarHeader}
</div>
) : (
renderBrand(compact)
);
const renderFooter = (compact: boolean) => {
if (sidebarFooter === undefined) {
return user ? (
<AppShellUserFooter
user={user}
collapsed={compact}
closeNavigation={closeNavigation}
/>
) : null;
}
return (
<div className="mt-auto shrink-0 border-t border-border p-2">
{typeof sidebarFooter === "function"
? sidebarFooter({ closeNavigation, collapsed: compact })
: sidebarFooter}
</div>
);
};
const renderCollapseControl = (rail = false) => (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon"
className={cn(
"hidden shrink-0 md:inline-flex",
rail && "group relative"
)}
aria-label={
collapsed
? (labels?.expandSidebar ?? "Expand sidebar")
: (labels?.collapseSidebar ?? "Collapse sidebar")
}
aria-expanded={!collapsed}
aria-controls={sidebarId}
onClick={() => {
if (controlledCollapsed === undefined) {
setInternalCollapsed(!collapsed);
}
onCollapsedChange?.(!collapsed);
}}
>
{rail && collapsed && (
<span
aria-hidden="true"
className="absolute flex size-6 items-center justify-center font-semibold group-hover:opacity-0 group-focus-visible:opacity-0"
>
{brand.icon ?? brand.name.slice(0, 1)}
</span>
)}
<span
className={cn(
"flex",
rail &&
collapsed &&
"opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"
)}
>
{collapsed ? (
<PanelLeftOpenIcon aria-hidden="true" />
) : (
<PanelLeftCloseIcon aria-hidden="true" />
)}
</span>
</Button>
}
/>
<TooltipContent side="right">
{collapsed
? (labels?.expandSidebar ?? "Expand sidebar")
: (labels?.collapseSidebar ?? "Collapse sidebar")}
</TooltipContent>
</Tooltip>
);
const renderMobileNavigation = () => (
<Drawer
title={sidebarHeader ? navigationLabel : brand.name}
open={mobileOpen}
onOpenChange={setMobileOpen}
swipeDirection={showHeader ? "left" : "right"}
contentProps={{
closeButtonLabel: labels?.closeNavigation ?? "Close navigation",
}}
showSwipeHandle={false}
trigger={
<Button
variant="ghost"
size="icon"
className={cn(
"md:hidden",
!showHeader &&
"fixed right-4 top-[max(1rem,env(safe-area-inset-top))] z-20 bg-background ring-1 ring-border"
)}
aria-label={labels?.openNavigation ?? "Open navigation"}
>
<MenuIcon aria-hidden="true" />
</Button>
}
>
<DrawerBody className="flex min-h-0 flex-1 flex-col p-0">
{sidebarHeader && (
<div className="flex h-16 shrink-0 items-center border-b border-border p-2">
{renderSidebarHeader(false)}
</div>
)}
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
{renderContextNavigation(false, true)}
</div>
{renderFooter(false)}
</DrawerBody>
</Drawer>
);
const renderDesktopIdentity = () => (
<div
data-slot="app-shell-sidebar-header"
className={cn(
"flex h-16 shrink-0 items-center gap-1 border-b border-border px-2",
collapsed && "flex-col justify-center gap-0",
collapsed && (sidebarHeader || brand.href) && "h-auto gap-1 py-2"
)}
>
{(!collapsed || sidebarHeader || brand.href) &&
renderSidebarHeader(collapsed)}
{renderCollapseControl(!sidebarHeader && !brand.href)}
</div>
);
return (
<TooltipProvider>
<div
data-slot="app-shell"
data-collapsed={collapsed}
className={cn(
"flex min-h-svh overflow-x-clip bg-background text-foreground",
className
)}
>
<a
href={`#${contentId}`}
className="sr-only z-50 rounded-md bg-background p-3 focus:not-sr-only focus:fixed focus:left-4 focus:top-4"
>
{labels?.skipToContent ?? "Skip to content"}
</a>
<motion.aside
id={sidebarId}
data-slot="app-shell-sidebar"
aria-label={labels?.sidebar ?? "Sidebar"}
initial={false}
animate={{ width: collapsed ? "4rem" : "15rem" }}
transition={sidebarTransition}
className="sticky top-0 hidden h-svh shrink-0 flex-col overflow-hidden border-r border-border bg-sidebar text-sidebar-foreground md:flex"
>
{renderDesktopIdentity()}
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
{renderContextNavigation(collapsed)}
</div>
{renderFooter(collapsed)}
</motion.aside>
<div className="flex min-w-0 flex-1 flex-col">
{!showHeader && (
<>
<div
data-slot="app-shell-brand-tab"
className="fixed left-1/2 top-0 z-20 max-w-[calc(100%-8rem)] -translate-x-1/2 rounded-b-xl bg-sidebar px-5 pb-3 pt-[max(.75rem,env(safe-area-inset-top))] text-sidebar-foreground before:absolute before:-left-3 before:top-0 before:size-3 before:rounded-tr-xl before:shadow-[4px_-4px_0_4px_var(--sidebar)] after:absolute after:-right-3 after:top-0 after:size-3 after:rounded-tl-xl after:shadow-[-4px_-4px_0_4px_var(--sidebar)] md:hidden"
>
<span className="block truncate text-sm font-semibold">
{brand.name}
</span>
</div>
{renderMobileNavigation()}
</>
)}
{showHeader && (
<header
data-slot="app-shell-header"
className="sticky top-0 z-20 flex h-16 shrink-0 items-center gap-3 border-b border-border bg-background px-4 sm:px-6"
>
{renderMobileNavigation()}
{onSearch && (
<Button
variant="outline"
onClick={onSearch}
className="min-w-0 flex-1 justify-start text-muted-foreground sm:max-w-sm"
>
<SearchIcon aria-hidden="true" />
<span className="truncate">{searchLabel}</span>
<kbd className="ml-auto hidden text-xs sm:inline">
⌘ / Ctrl K
</kbd>
</Button>
)}
{header}
{actions && (
<div className="ml-auto flex shrink-0 items-center gap-2">
{actions}
</div>
)}
</header>
)}
<main
id={contentId}
data-slot="app-shell-main"
tabIndex={-1}
className={cn(
"min-w-0 flex-1 outline-none",
!showHeader &&
"pt-[calc(4.5rem+env(safe-area-inset-top))] md:pt-0",
contentClassName
)}
>
{children}
</main>
</div>
</div>
</TooltipProvider>
);
};
Credits
- 7ovr: Original app-shell-3 icon-rail layout inspiration, adapted through Universe. The free upstream block is published under MIT-0.
On This Page
InstallationDependenciesUsageBuilt-in identity and localized controlsStatesExpandedCollapsed icon railMobile drawerExamplesFull-bleed contentWorkspace and account controlsHierarchy and controlled collapseNavigation item contractOptional searchConsumer setup and optional storiesBehavior & AccessibilityPropsSourceCredits