Blocksblock
App shell
Sidebar with a team or personal-account and organization switcher, user menu, header with breadcrumbs, and a ⌘K command menu.
Preview
Open full screenInstallation
npx shadcn@latest add @particular/app-shellnpm dependencies
- cn@^0.4.0
- lucide-react@^1.51.0
Installs these registry items
Usage
"use client"
import * as React from "react"
import { BoxIcon, CreditCardIcon, DatabaseIcon, GaugeIcon, KeyRoundIcon, LayoutDashboardIcon, RocketIcon, SettingsIcon } from "lucide-react"
import { AppShell, type AppShellNavGroup } from "@/components/app-shell"
import { Button } from "@/components/ui/button"
import { MetricCard } from "@/components/ui/metric-card"
import {
PageHeader,
PageHeaderActions,
PageHeaderContent,
PageHeaderDescription,
PageHeaderTitle,
} from "@/components/ui/page-header"
import { toast } from "@/components/ui/toast"
const navigation: AppShellNavGroup[] = [
{
label: "Project",
items: [
{ title: "Overview", href: "#overview", icon: LayoutDashboardIcon, active: true },
{ title: "Deployments", href: "#deployments", icon: RocketIcon, badge: 3 },
{ title: "Databases", href: "#databases", icon: DatabaseIcon },
{ title: "Usage", href: "#usage", icon: GaugeIcon },
],
},
{
label: "Team",
items: [
{ title: "Projects", href: "#projects", icon: BoxIcon },
{ title: "API keys", href: "#api-keys", icon: KeyRoundIcon },
{ title: "Billing", href: "#billing", icon: CreditCardIcon },
{ title: "Settings", href: "#settings", icon: SettingsIcon },
],
},
]
const teams = [
{ id: "ada", name: "Ada Lovelace", plan: "Personal account", kind: "personal" as const },
{ id: "particular", name: "Particular.Tech", plan: "Pro", kind: "organization" as const },
{ id: "analytical", name: "Analytical Engines", plan: "Hobby", kind: "organization" as const },
]
export function Demo() {
const [teamId, setTeamId] = React.useState("particular")
return (
<AppShell
teams={teams}
activeTeamId={teamId}
onTeamChange={setTeamId}
onCreateTeam={() => toast.add({ title: "New organization" })}
createTeamLabel="New organization"
navigation={navigation}
user={{ name: "Ada Lovelace", email: "ada@particular.tech" }}
onSignOut={() => toast.add({ title: "Signed out" })}
settingsHref="#settings"
breadcrumbs={[{ label: "Particular.Tech", href: "#" }, { label: "acme-web", href: "#" }, { label: "Overview" }]}
onNavigate={(href) => toast.add({ title: `Navigate to ${href}` })}
>
<div className="mx-auto flex max-w-6xl flex-col gap-6">
<PageHeader>
<PageHeaderContent>
<PageHeaderTitle>acme-web</PageHeaderTitle>
<PageHeaderDescription>Production is healthy. Last deployed 4 minutes ago from main.</PageHeaderDescription>
</PageHeaderContent>
<PageHeaderActions>
<Button variant="outline">Visit</Button>
<Button>
<RocketIcon data-icon="inline-start" />
Deploy
</Button>
</PageHeaderActions>
</PageHeader>
<div className="grid gap-4 md:grid-cols-3">
<MetricCard label="Requests" value="2.05M" delta={0.124} deltaLabel="vs last week" trend={[12, 18, 14, 22, 25, 21, 30, 34]} />
<MetricCard label="p95 latency" value="96 ms" delta={-0.08} invertDelta deltaLabel="vs last week" trend={[120, 118, 110, 105, 108, 99, 97, 96]} />
<MetricCard label="Error rate" value="0.42%" delta={0.31} invertDelta deltaLabel="vs last week" trend={[0.2, 0.25, 0.22, 0.3, 0.28, 0.35, 0.4, 0.42]} />
</div>
</div>
</AppShell>
)
}Source
"use client"
import * as React from "react"
import { CheckIcon, ChevronsUpDownIcon, LogOutIcon, PlusIcon, SearchIcon, SettingsIcon, type LucideIcon } from "lucide-react"
import { cn } from "cn"
import { LogoMark } from "@/components/logo"
import { ThemeToggle } from "@/components/theme-toggle"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import { Button } from "@/components/ui/button"
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Kbd, KbdGroup } from "@/components/ui/kbd"
import { Separator } from "@/components/ui/separator"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarRail,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar"
export type AppShellNavItem = {
title: string
href: string
icon: LucideIcon
active?: boolean
badge?: React.ReactNode
}
export type AppShellNavGroup = { label?: string; items: AppShellNavItem[] }
export type AppShellTeam = {
id: string
name: string
plan?: string
/** Set on every entry to split the switcher into a personal account and organizations, as on GitHub. */
kind?: "personal" | "organization"
avatarUrl?: string
}
export type AppShellUser = { name: string; email: string; avatarUrl?: string }
export type AppShellProps = {
teams: AppShellTeam[]
activeTeamId: string
onTeamChange: (teamId: string) => void
/** Adds a create entry at the bottom of the switcher. */
onCreateTeam?: () => void
createTeamLabel?: string
navigation: AppShellNavGroup[]
user: AppShellUser
onSignOut: () => void
settingsHref?: string
breadcrumbs?: { label: string; href?: string }[]
/** Element used for navigation links, e.g. Next.js `Link`. Receives `href`. */
linkComponent?: React.ElementType<{ href: string }>
/** Called when a command-menu entry is chosen. Defaults to a full page load; pass `router.push` for client navigation. */
onNavigate?: (href: string) => void
headerActions?: React.ReactNode
defaultSidebarOpen?: boolean
children: React.ReactNode
}
function initials(name: string) {
return name
.split(/\s+/)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("")
}
function TeamAvatar({ team, className }: { team: AppShellTeam; className?: string }) {
const shape = team.kind === "personal" ? "rounded-full" : "rounded-md"
return (
<Avatar className={cn(shape, className)}>
{team.avatarUrl && <AvatarImage src={team.avatarUrl} alt="" />}
<AvatarFallback className={cn(shape, "text-[0.625rem] font-medium")}>{initials(team.name)}</AvatarFallback>
</Avatar>
)
}
function TeamItem({ team, active, onSelect }: { team: AppShellTeam; active: boolean; onSelect: () => void }) {
return (
<DropdownMenuItem onClick={onSelect}>
<TeamAvatar team={team} className="size-5" />
<span className="flex-1 truncate">{team.name}</span>
{active && <CheckIcon className="text-brand" />}
</DropdownMenuItem>
)
}
function TeamSwitcher({
teams,
activeTeamId,
onTeamChange,
onCreateTeam,
createTeamLabel = "New team",
}: Pick<AppShellProps, "teams" | "activeTeamId" | "onTeamChange" | "onCreateTeam" | "createTeamLabel">) {
const active = teams.find((team) => team.id === activeTeamId) ?? teams[0]
const grouped = teams.some((team) => team.kind !== undefined)
const groups = grouped
? [
{ label: "Personal account", teams: teams.filter((team) => team.kind === "personal") },
{ label: "Organizations", teams: teams.filter((team) => team.kind === "organization") },
].filter((group) => group.teams.length > 0)
: [{ label: "Teams", teams }]
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={<SidebarMenuButton size="lg" className="data-popup-open:bg-sidebar-accent" />}
>
{active?.kind ? <TeamAvatar team={active} className="size-8" /> : <LogoMark className="size-8" />}
<span className="grid flex-1 text-left leading-tight">
<span className="truncate text-sm font-medium">{active?.name}</span>
{active?.plan && <span className="truncate text-xs text-muted-foreground">{active.plan}</span>}
</span>
<ChevronsUpDownIcon className="ml-auto text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-(--anchor-width) min-w-56" align="start">
{groups.map((group, index) => (
<React.Fragment key={group.label}>
{index > 0 && <DropdownMenuSeparator />}
<DropdownMenuGroup>
<DropdownMenuLabel>{group.label}</DropdownMenuLabel>
{group.teams.map((team) => (
<TeamItem key={team.id} team={team} active={team.id === active?.id} onSelect={() => onTeamChange(team.id)} />
))}
</DropdownMenuGroup>
</React.Fragment>
))}
{onCreateTeam && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onCreateTeam}>
<PlusIcon />
{createTeamLabel}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
function UserMenu({ user, onSignOut, settingsHref, Link }: Pick<AppShellProps, "user" | "onSignOut" | "settingsHref"> & { Link: React.ElementType<{ href: string }> }) {
const { isMobile, setOpenMobile } = useSidebar()
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger render={<SidebarMenuButton size="lg" className="data-popup-open:bg-sidebar-accent" />}>
<Avatar className="size-8 rounded-lg">
{user.avatarUrl && <AvatarImage src={user.avatarUrl} alt="" />}
<AvatarFallback className="rounded-lg">{initials(user.name)}</AvatarFallback>
</Avatar>
<span className="grid flex-1 text-left leading-tight">
<span className="truncate text-sm font-medium">{user.name}</span>
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
</span>
<ChevronsUpDownIcon className="ml-auto text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-(--anchor-width) min-w-56" side="top" align="start">
{settingsHref && (
<>
<DropdownMenuItem
render={<Link href={settingsHref} />}
onClick={() => {
if (isMobile) setOpenMobile(false)
}}
>
<SettingsIcon />
Account settings
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={onSignOut}>
<LogOutIcon />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
function SidebarNavigation({
navigation,
Link,
}: {
navigation: AppShellNavGroup[]
Link: React.ElementType<{ href: string }>
}) {
const { isMobile, setOpenMobile } = useSidebar()
return navigation.map((group, index) => (
<SidebarGroup key={group.label ?? index}>
{group.label && <SidebarGroupLabel>{group.label}</SidebarGroupLabel>}
<SidebarMenu>
{group.items.map((item) => (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton
isActive={item.active}
tooltip={item.title}
render={<Link href={item.href} />}
onClick={() => {
if (isMobile) setOpenMobile(false)
}}
>
<item.icon />
<span>{item.title}</span>
</SidebarMenuButton>
{item.badge !== undefined && <SidebarMenuBadge>{item.badge}</SidebarMenuBadge>}
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroup>
))
}
function CommandMenu({
navigation,
open,
onOpenChange,
onNavigate,
}: {
navigation: AppShellNavGroup[]
open: boolean
onOpenChange: (open: boolean) => void
onNavigate: (href: string) => void
}) {
return (
<CommandDialog open={open} onOpenChange={onOpenChange} title="Go to" description="Search pages">
<Command>
<CommandInput placeholder="Search pages…" />
<CommandList>
<CommandEmpty>No pages found.</CommandEmpty>
{navigation.map((group, index) => (
<CommandGroup key={group.label ?? index} heading={group.label}>
{group.items.map((item) => (
<CommandItem
key={item.href}
value={`${group.label ?? ""} ${item.title}`}
onSelect={() => {
onOpenChange(false)
onNavigate(item.href)
}}
>
<item.icon />
{item.title}
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</Command>
</CommandDialog>
)
}
function AppShell({
teams,
activeTeamId,
onTeamChange,
onCreateTeam,
createTeamLabel,
navigation,
user,
onSignOut,
settingsHref,
breadcrumbs = [],
linkComponent: Link = "a",
onNavigate = (href) => window.location.assign(href),
headerActions,
defaultSidebarOpen = true,
children,
}: AppShellProps) {
const [commandOpen, setCommandOpen] = React.useState(false)
React.useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
setCommandOpen((open) => !open)
}
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [])
return (
<SidebarProvider defaultOpen={defaultSidebarOpen}>
<Sidebar collapsible="icon" variant="inset">
<SidebarHeader>
<TeamSwitcher
teams={teams}
activeTeamId={activeTeamId}
onTeamChange={onTeamChange}
onCreateTeam={onCreateTeam}
createTeamLabel={createTeamLabel}
/>
</SidebarHeader>
<SidebarContent>
<SidebarNavigation navigation={navigation} Link={Link} />
</SidebarContent>
<SidebarFooter>
<UserMenu user={user} onSignOut={onSignOut} settingsHref={settingsHref} Link={Link} />
</SidebarFooter>
<SidebarRail />
</Sidebar>
<SidebarInset>
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background/80 px-4 backdrop-blur md:rounded-t-xl">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-1 data-vertical:h-4 data-vertical:self-center" />
{breadcrumbs.length > 0 && (
<Breadcrumb className="min-w-0">
<BreadcrumbList>
{breadcrumbs.map((crumb, index) => (
<React.Fragment key={`${crumb.label}-${index}`}>
{index > 0 && <BreadcrumbSeparator className="hidden md:inline-flex" />}
<BreadcrumbItem className={index < breadcrumbs.length - 1 ? "hidden md:inline-flex" : undefined}>
{crumb.href && index < breadcrumbs.length - 1 ? (
<BreadcrumbLink render={<Link href={crumb.href} />}>{crumb.label}</BreadcrumbLink>
) : (
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
)}
</BreadcrumbItem>
</React.Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
)}
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="hidden w-56 justify-between text-muted-foreground sm:inline-flex"
onClick={() => setCommandOpen(true)}
>
<span className="inline-flex items-center gap-2">
<SearchIcon />
Search…
</span>
<KbdGroup>
<Kbd>⌘</Kbd>
<Kbd>K</Kbd>
</KbdGroup>
</Button>
<Button variant="ghost" size="icon-sm" className="sm:hidden" aria-label="Search" onClick={() => setCommandOpen(true)}>
<SearchIcon />
</Button>
{headerActions}
<ThemeToggle />
</div>
</header>
<div className="flex-1 p-4 md:p-6">{children}</div>
</SidebarInset>
<CommandMenu
navigation={navigation}
open={commandOpen}
onOpenChange={setCommandOpen}
onNavigate={onNavigate}
/>
</SidebarProvider>
)
}
export { AppShell }