Particular.Tech
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 screen

Installation

npx shadcn@latest add @particular/app-shell
npm dependencies
  • cn@^0.4.0
  • lucide-react@^1.51.0

Usage

app-shell-demo.tsx
"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

blocks/app-shell/app-shell.tsx
"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 }