Cosskit

Sidebar + Topbar App Shell

Application shell with a flush sidebar and a full application topbar carrying breadcrumbs, command search, notifications, and the account menu.

pnpm dlx shadcn@latest add @cosskit/app-shell-topbar

Requires the @cosskit namespace in your components.json.

Open full page

Code

import {
  BellIcon,
  BookOpenIcon,
  BuildingIcon,
  ChevronsUpDownIcon,
  CreditCardIcon,
  FileTextIcon,
  LayoutDashboardIcon,
  PlugIcon,
  PlusIcon,
  SearchIcon,
  SettingsIcon,
  SparklesIcon,
  UsersIcon,
  WorkflowIcon,
} from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Button } from "@/components/ui/button";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import {
  Menu,
  MenuItem,
  MenuPopup,
  MenuSeparator,
  MenuTrigger,
} from "@/components/ui/menu";
import { Separator } from "@/components/ui/separator";
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupContent,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInset,
  SidebarMenu,
  SidebarMenuBadge,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
  SidebarRail,
  SidebarTrigger,
} from "@/components/ui/sidebar";

// ---------------------------------------------------------------------------
// Navigation
// ---------------------------------------------------------------------------

const mainNav = [
  { icon: LayoutDashboardIcon, label: "Overview", href: "#" },
  { icon: UsersIcon, label: "Customers", href: "#", isActive: true, badge: "248" },
  { icon: FileTextIcon, label: "Invoices", href: "#", badge: "6" },
  { icon: WorkflowIcon, label: "Automations", href: "#" },
  { icon: BookOpenIcon, label: "Reports", href: "#" },
];

const workspaceNav = [
  { icon: UsersIcon, label: "Team", href: "#" },
  { icon: PlugIcon, label: "Integrations", href: "#" },
  { icon: SettingsIcon, label: "Settings", href: "#" },
];

// ---------------------------------------------------------------------------
// Page
//
// Differs from `app-shell-inset` in three ways worth knowing before you pick
// one: the sidebar is flush (`variant="sidebar"`) rather than an inset panel,
// it collapses off-canvas rather than to an icon rail, and the account menu
// lives in the topbar instead of the sidebar footer — which frees the footer
// for a plan/upgrade slot.
// ---------------------------------------------------------------------------

export default function AppShellTopbar() {
  return (
    <SidebarProvider className="[&_[data-slot=button]]:transition-[box-shadow,scale] [&_[data-slot=button]:not(.w-full)]:motion-safe:hover:scale-[1.05] [&_[data-slot=button].w-full]:motion-safe:hover:scale-[1.03]">
      <Sidebar variant="sidebar" collapsible="offcanvas">
        {/* Brand / workspace switcher */}
        <SidebarHeader>
          <SidebarMenu>
            <SidebarMenuItem>
              <Menu>
                <MenuTrigger
                  render={
                    <SidebarMenuButton
                      size="lg"
                      className="cursor-pointer data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
                    />
                  }
                >
                  <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary font-bold text-primary-foreground text-sm">
                    K
                  </div>
                  <div className="flex min-w-0 flex-col leading-tight">
                    <span className="truncate font-semibold text-sm">Kestrel</span>
                    <span className="truncate text-muted-foreground text-xs">
                      Growth plan
                    </span>
                  </div>
                  <ChevronsUpDownIcon className="ms-auto size-4 shrink-0" />
                </MenuTrigger>
                {/* Pointer cursor is a house rule; coss ships `cursor-default`
                    here by native-menu convention. It goes on the popup — which
                    portals out of this block, so no ancestor rule can reach it —
                    and never by editing coss source. The `-item` suffix covers
                    checkbox and radio items too, so later additions inherit it. */}
                <MenuPopup
                  align="start"
                  className="min-w-56 [&_[data-slot$='-item']]:cursor-pointer"
                  sideOffset={8}
                >
                  <MenuItem>
                    <BuildingIcon />
                    Kestrel
                  </MenuItem>
                  <MenuItem>
                    <BuildingIcon />
                    Harborview Media
                  </MenuItem>
                  <MenuSeparator />
                  <MenuItem>
                    <PlusIcon />
                    New workspace
                  </MenuItem>
                </MenuPopup>
              </Menu>
            </SidebarMenuItem>
          </SidebarMenu>
        </SidebarHeader>

        <SidebarContent>
          <SidebarGroup>
            <SidebarGroupLabel>Main</SidebarGroupLabel>
            <SidebarGroupContent>
              <SidebarMenu>
                {mainNav.map(({ icon: Icon, label, href, isActive, badge }) => (
                  <SidebarMenuItem key={label}>
                    <SidebarMenuButton
                      isActive={isActive}
                      render={<a href={href} />}
                      tooltip={label}
                    >
                      <Icon />
                      <span>{label}</span>
                    </SidebarMenuButton>
                    {badge && <SidebarMenuBadge>{badge}</SidebarMenuBadge>}
                  </SidebarMenuItem>
                ))}
              </SidebarMenu>
            </SidebarGroupContent>
          </SidebarGroup>

          <SidebarGroup>
            <SidebarGroupLabel>Workspace</SidebarGroupLabel>
            <SidebarGroupContent>
              <SidebarMenu>
                {workspaceNav.map(({ icon: Icon, label, href }) => (
                  <SidebarMenuItem key={label}>
                    <SidebarMenuButton render={<a href={href} />} tooltip={label}>
                      <Icon />
                      <span>{label}</span>
                    </SidebarMenuButton>
                  </SidebarMenuItem>
                ))}
              </SidebarMenu>
            </SidebarGroupContent>
          </SidebarGroup>
        </SidebarContent>

        {/* Footer is free for a plan slot because the account menu is in the
            topbar. `bg-sidebar-accent` keeps it on the sidebar's own surface
            ramp rather than the page canvas. */}
        <SidebarFooter>
          <div className="flex flex-col gap-2 rounded-lg bg-sidebar-accent p-3">
            <div className="flex items-center gap-2">
              <SparklesIcon className="size-4 shrink-0" />
              <span className="font-medium text-sm">8 of 10 seats used</span>
            </div>
            <p className="text-muted-foreground text-xs">
              Upgrade to Scale for unlimited seats, audit logs, and SSO.
            </p>
            <Button className="w-full" size="sm">
              Upgrade plan
            </Button>
          </div>
        </SidebarFooter>

        <SidebarRail />
      </Sidebar>

      <SidebarInset>
        {/* Application topbar */}
        <header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background px-4">
          <SidebarTrigger className="-ms-1" />
          <Separator className="h-5 max-sm:hidden" orientation="vertical" />

          <Breadcrumb className="max-sm:hidden">
            <BreadcrumbList>
              <BreadcrumbItem>
                <BreadcrumbLink render={<a href="#" />}>Kestrel</BreadcrumbLink>
              </BreadcrumbItem>
              <BreadcrumbSeparator />
              <BreadcrumbItem>
                <BreadcrumbLink render={<a href="#" />}>Customers</BreadcrumbLink>
              </BreadcrumbItem>
              <BreadcrumbSeparator />
              <BreadcrumbItem>
                <BreadcrumbPage>Acme Industries</BreadcrumbPage>
              </BreadcrumbItem>
            </BreadcrumbList>
          </Breadcrumb>

          <span className="font-medium text-sm sm:hidden">Acme Industries</span>

          <div className="ms-auto flex items-center gap-2">
            {/* Command-palette affordance. Inert here — wire it to your own
                palette (coss ships `Command`) in the consuming app. */}
            <Button
              className="w-56 justify-start gap-2 text-muted-foreground max-lg:hidden"
              size="sm"
              variant="outline"
            >
              <SearchIcon />
              <span className="flex-1 text-left">Search customers…</span>
              <KbdGroup>
                <Kbd>⌘</Kbd>
                <Kbd>K</Kbd>
              </KbdGroup>
            </Button>
            <Button
              aria-label="Search"
              className="lg:hidden"
              size="icon-sm"
              variant="ghost"
            >
              <SearchIcon />
            </Button>

            <Button
              aria-label="Notifications — 3 unread"
              className="relative"
              size="icon-sm"
              variant="ghost"
            >
              <BellIcon />
              <span className="absolute end-1.5 top-1.5 size-1.5 rounded-full bg-primary" />
            </Button>

            <Separator className="h-5 max-sm:hidden" orientation="vertical" />

            <Menu>
              <MenuTrigger
                render={
                  <Button
                    aria-label="Account menu"
                    className="rounded-full"
                    size="icon-sm"
                    variant="ghost"
                  />
                }
              >
                <Avatar className="size-7 rounded-full">
                  <AvatarFallback className="text-xs">NR</AvatarFallback>
                </Avatar>
              </MenuTrigger>
              <MenuPopup
                align="end"
                className="min-w-56 [&_[data-slot$='-item']]:cursor-pointer"
                sideOffset={8}
              >
                <div className="flex flex-col gap-0.5 px-2 py-1.5">
                  <p className="font-medium text-sm leading-none">Nadia Rahim</p>
                  <p className="truncate text-muted-foreground text-xs">
                    nadia@kestrel.io
                  </p>
                </div>
                <MenuSeparator />
                <MenuItem>
                  <SettingsIcon />
                  Account settings
                </MenuItem>
                <MenuItem>
                  <CreditCardIcon />
                  Billing
                </MenuItem>
                <MenuItem>
                  <UsersIcon />
                  Invite teammates
                </MenuItem>
                <MenuSeparator />
                <MenuItem variant="destructive">Sign out</MenuItem>
              </MenuPopup>
            </Menu>
          </div>
        </header>

        {/* Page content */}
        <div className="flex flex-1 flex-col gap-6 p-4 sm:p-6">
          <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
            <div className="flex flex-col gap-1">
              <h1 className="font-semibold text-2xl tracking-tight">
                Acme Industries
              </h1>
              <p className="text-muted-foreground text-sm">
                Enterprise account · 42 seats · customer since March 2024
              </p>
            </div>
            <Button size="sm">
              <PlusIcon />
              New invoice
            </Button>
          </div>

          <div className="flex min-h-80 flex-1 items-center justify-center rounded-xl border border-dashed">
            <div className="flex flex-col items-center gap-2 text-center">
              <LayoutDashboardIcon className="size-8 text-muted-foreground/50" />
              <p className="font-medium text-muted-foreground text-sm">
                Your page content goes here
              </p>
              <p className="max-w-xs text-muted-foreground/70 text-xs">
                Compose this area from other blocks — a data table, KPI cards, or
                a settings form.
              </p>
            </div>
          </div>
        </div>
      </SidebarInset>
    </SidebarProvider>
  );
}

Dependencies

  • @coss/ui