← Optimotive UI/templates/Dashboard Shell
template

Dashboard Shell

Full-page layout with collapsible sidebar, header, breadcrumb nav, and mobile sheet drawer.

Installation

npx shadcn@latest add https://optimotive-ui.dev.optimotive-tools.co.uk/registry.json dashboard-shell

Preview

Source

templates/dashboard-shell.tsx
"use client"

import * as React from "react"
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 {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuShortcut,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

export interface NavItem {
  id: string
  label: string
  icon: React.ReactNode
  href?: string
  badge?: string | number
  isActive?: boolean
}

export interface NavSection {
  title?: string
  items: NavItem[]
}

export interface BreadcrumbSegment {
  label: string
  href?: string
}

export interface DashboardUser {
  name: string
  email: string
  avatarUrl?: string
  role?: string
}

export interface DashboardShellProps {
  navSections?: NavSection[]
  breadcrumbs?: BreadcrumbSegment[]
  activeNavId?: string
  user?: DashboardUser
  children?: React.ReactNode
  brandName?: string
  brandIcon?: React.ReactNode
  /** Optional right-side header slot (search bar, notification bell, etc.) */
  headerSlot?: React.ReactNode
  onNavItemClick?: (item: NavItem) => void
  onUserAction?: (action: "profile" | "settings" | "billing" | "logout") => void
}

// ---------------------------------------------------------------------------
// Inline SVG icons (no external icon dependency)
// ---------------------------------------------------------------------------

const Icon = {
  Grid: (p: React.SVGProps<SVGSVGElement>) => (
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}>
      <rect x="1" y="1" width="6" height="6" rx="1" />
      <rect x="9" y="1" width="6" height="6" rx="1" />
      <rect x="1" y="9" width="6" height="6" rx="1" />
      <rect x="9" y="9" width="6" height="6" rx="1" />
    </svg>
  ),
  Agents: (p: React.SVGProps<SVGSVGElement>) => (
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}>
      <circle cx="8" cy="5" r="2.5" />
      <path d="M2 13c0-3.314 2.686-5 6-5s6 1.686 6 5" strokeLinecap="round" />
    </svg>
  ),
  Activity: (p: React.SVGProps<SVGSVGElement>) => (
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}>
      <polyline points="1,9 4,6 7,10 10,4 13,7 15,5" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  ),
  Star: (p: React.SVGProps<SVGSVGElement>) => (
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}>
      <polygon points="8,1 10,6 15,6 11,9.5 12.5,15 8,12 3.5,15 5,9.5 1,6 6,6" strokeLinejoin="round" />
    </svg>
  ),
  Box: (p: React.SVGProps<SVGSVGElement>) => (
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}>
      <rect x="2" y="2" width="12" height="12" rx="2" />
      <path d="M5 8h6M8 5v6" strokeLinecap="round" />
    </svg>
  ),
  Settings: (p: React.SVGProps<SVGSVGElement>) => (
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}>
      <circle cx="8" cy="8" r="2" />
      <path d="M8 1v2M8 13v2M1 8h2M13 8h2M3.05 3.05l1.41 1.41M11.54 11.54l1.41 1.41M3.05 12.95l1.41-1.41M11.54 4.46l1.41-1.41" strokeLinecap="round" />
    </svg>
  ),
  Menu: (p: React.SVGProps<SVGSVGElement>) => (
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}>
      <path d="M2 4h12M2 8h12M2 12h12" strokeLinecap="round" />
    </svg>
  ),
  Logout: (p: React.SVGProps<SVGSVGElement>) => (
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}>
      <path d="M6 2H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3" strokeLinecap="round" />
      <path d="M10 11l3-3-3-3" strokeLinecap="round" strokeLinejoin="round" />
      <path d="M13 8H6" strokeLinecap="round" />
    </svg>
  ),
  ChevronLeft: (p: React.SVGProps<SVGSVGElement>) => (
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}>
      <path d="M10 3L6 8l4 5" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  ),
  ChevronDown: (p: React.SVGProps<SVGSVGElement>) => (
    <svg viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" {...p}>
      <path d="M3 5l3 3 3-3" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  ),
}

const SZ = "size-4"

// ---------------------------------------------------------------------------
// Default demo data
// ---------------------------------------------------------------------------

export const DEFAULT_NAV_SECTIONS: NavSection[] = [
  {
    items: [
      { id: "overview", label: "Overview", icon: <Icon.Grid className={SZ} /> },
      { id: "agents", label: "Agents", icon: <Icon.Agents className={SZ} />, badge: 7 },
      { id: "activity", label: "Activity", icon: <Icon.Activity className={SZ} />, badge: "Live" },
    ],
  },
  {
    title: "Platform",
    items: [
      { id: "skills", label: "Skills", icon: <Icon.Star className={SZ} /> },
      { id: "outputs", label: "Outputs", icon: <Icon.Box className={SZ} /> },
    ],
  },
  {
    title: "System",
    items: [
      { id: "settings", label: "Settings", icon: <Icon.Settings className={SZ} /> },
    ],
  },
]

export const DEFAULT_USER: DashboardUser = {
  name: "Andy",
  email: "andy@andycode.ai",
  role: "Platform Owner",
}

export const DEFAULT_BREADCRUMBS: BreadcrumbSegment[] = [
  { label: "Andy Code", href: "/" },
  { label: "Overview" },
]

// ---------------------------------------------------------------------------
// SideNavItem
// ---------------------------------------------------------------------------

interface SideNavItemProps {
  item: NavItem
  isActive: boolean
  collapsed: boolean
  onClick?: () => void
}

function SideNavItem({ item, isActive, collapsed, onClick }: SideNavItemProps) {
  const btn = (
    <button
      onClick={onClick}
      className={cn(
        "group relative flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all duration-150",
        isActive
          ? "bg-primary text-primary-foreground shadow-sm"
          : "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
        collapsed && "justify-center px-2"
      )}
    >
      <span className={cn("shrink-0", isActive && "scale-105")}>{item.icon}</span>
      {!collapsed && (
        <>
          <span className="flex-1 truncate text-left">{item.label}</span>
          {item.badge !== undefined && (
            <Badge
              variant={isActive ? "secondary" : "outline"}
              className={cn(
                "ml-auto px-1.5 py-0 text-[10px] leading-4 tabular-nums",
                isActive && "bg-primary-foreground/20 text-primary-foreground border-primary-foreground/30"
              )}
            >
              {item.badge}
            </Badge>
          )}
        </>
      )}
    </button>
  )

  if (!collapsed) return btn

  return (
    <TooltipProvider delayDuration={150}>
      <Tooltip>
        <TooltipTrigger asChild>{btn}</TooltipTrigger>
        <TooltipContent side="right" className="font-medium">{item.label}</TooltipContent>
      </Tooltip>
    </TooltipProvider>
  )
}

// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------

interface SidebarProps {
  sections: NavSection[]
  activeNavId: string
  brandName: string
  brandIcon?: React.ReactNode
  collapsed?: boolean
  onNavItemClick?: (item: NavItem) => void
}

function Sidebar({ sections, activeNavId, brandName, brandIcon, collapsed = false, onNavItemClick }: SidebarProps) {
  return (
    <div className="flex h-full flex-col">
      {/* Brand header */}
      <div className={cn("flex h-14 shrink-0 items-center gap-2.5 border-b border-border px-4", collapsed && "justify-center px-0")}>
        {brandIcon ?? (
          <span className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground text-xs font-bold">
            {brandName.slice(0, 2).toUpperCase()}
          </span>
        )}
        {!collapsed && (
          <span className="text-sm font-semibold tracking-tight truncate">{brandName}</span>
        )}
      </div>

      {/* Nav sections */}
      <ScrollArea className="flex-1 px-2 py-3">
        <nav className="flex flex-col gap-4">
          {sections.map((section, si) => (
            <div key={si} className="flex flex-col gap-0.5">
              {section.title && !collapsed && (
                <p className="mb-1 px-3 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/60">
                  {section.title}
                </p>
              )}
              {section.items.map((item) => (
                <SideNavItem
                  key={item.id}
                  item={item}
                  isActive={item.id === activeNavId}
                  collapsed={collapsed}
                  onClick={() => onNavItemClick?.(item)}
                />
              ))}
            </div>
          ))}
        </nav>
      </ScrollArea>
    </div>
  )
}

// ---------------------------------------------------------------------------
// UserMenu
// ---------------------------------------------------------------------------

interface UserMenuProps {
  user: DashboardUser
  onAction?: DashboardShellProps["onUserAction"]
}

function UserMenu({ user, onAction }: UserMenuProps) {
  const initials = user.name.split(" ").slice(0, 2).map((n) => n[0]?.toUpperCase() ?? "").join("")
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="ghost" size="sm" className="gap-2 pl-1 pr-2 h-9">
          <Avatar className="size-7 border border-border/50">
            {user.avatarUrl && <AvatarImage src={user.avatarUrl} alt={user.name} />}
            <AvatarFallback className="text-xs font-semibold">{initials}</AvatarFallback>
          </Avatar>
          <span className="hidden sm:block text-sm font-medium max-w-28 truncate">{user.name}</span>
          <Icon.ChevronDown className="size-3 text-muted-foreground" />
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end" className="w-52">
        <DropdownMenuLabel className="flex flex-col gap-0.5 font-normal">
          <span className="font-semibold">{user.name}</span>
          <span className="text-xs text-muted-foreground truncate">{user.email}</span>
          {user.role && (
            <Badge variant="secondary" className="w-fit mt-1 px-1.5 py-0 text-[10px]">{user.role}</Badge>
          )}
        </DropdownMenuLabel>
        <DropdownMenuSeparator />
        <DropdownMenuGroup>
          <DropdownMenuItem onClick={() => onAction?.("profile")}>
            Profile <DropdownMenuShortcut>⌘P</DropdownMenuShortcut>
          </DropdownMenuItem>
          <DropdownMenuItem onClick={() => onAction?.("settings")}>
            Settings <DropdownMenuShortcut>⌘,</DropdownMenuShortcut>
          </DropdownMenuItem>
          <DropdownMenuItem onClick={() => onAction?.("billing")}>Billing</DropdownMenuItem>
        </DropdownMenuGroup>
        <DropdownMenuSeparator />
        <DropdownMenuItem
          onClick={() => onAction?.("logout")}
          className="text-destructive focus:text-destructive"
        >
          <Icon.Logout className={SZ} />
          Log out
          <DropdownMenuShortcut>⇧⌘Q</DropdownMenuShortcut>
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

// ---------------------------------------------------------------------------
// Header
// ---------------------------------------------------------------------------

interface HeaderProps {
  breadcrumbs: BreadcrumbSegment[]
  user: DashboardUser
  onUserAction?: DashboardShellProps["onUserAction"]
  headerSlot?: React.ReactNode
  mobileTrigger: React.ReactNode
}

function Header({ breadcrumbs, user, onUserAction, headerSlot, mobileTrigger }: HeaderProps) {
  return (
    <header className="flex h-14 shrink-0 items-center gap-2 border-b border-border bg-background px-4">
      {mobileTrigger}
      <Breadcrumb className="flex-1">
        <BreadcrumbList>
          {breadcrumbs.map((crumb, i) => {
            const isLast = i === breadcrumbs.length - 1
            return (
              <React.Fragment key={i}>
                <BreadcrumbItem>
                  {isLast ? (
                    <BreadcrumbPage>{crumb.label}</BreadcrumbPage>
                  ) : (
                    <BreadcrumbLink href={crumb.href ?? "#"}>{crumb.label}</BreadcrumbLink>
                  )}
                </BreadcrumbItem>
                {!isLast && <BreadcrumbSeparator />}
              </React.Fragment>
            )
          })}
        </BreadcrumbList>
      </Breadcrumb>
      <div className="flex items-center gap-2">
        {headerSlot}
        <UserMenu user={user} onAction={onUserAction} />
      </div>
    </header>
  )
}

// ---------------------------------------------------------------------------
// DashboardShell — main export
// ---------------------------------------------------------------------------

/**
 * DashboardShell
 *
 * Full-page dashboard layout with:
 * - Collapsible sidebar on desktop
 * - Sheet-based drawer on mobile (lg breakpoint)
 * - Sticky header with breadcrumb + user dropdown
 * - ScrollArea-managed main content
 *
 * Built entirely from shadcn/ui primitives — no external layout libraries.
 *
 * ### shadcn dependencies
 * Avatar, Breadcrumb, Button, Badge, DropdownMenu,
 * ScrollArea, Separator, Sheet, Tooltip
 *
 * ### Usage
 * ```tsx
 * <DashboardShell breadcrumbs={[{ label: "Home", href: "/" }, { label: "Agents" }]}>
 *   <AgentsPage />
 * </DashboardShell>
 * ```
 */
export function DashboardShell({
  navSections = DEFAULT_NAV_SECTIONS,
  breadcrumbs = DEFAULT_BREADCRUMBS,
  activeNavId = "overview",
  user = DEFAULT_USER,
  children,
  brandName = "Andy Code",
  brandIcon,
  headerSlot,
  onNavItemClick,
  onUserAction,
}: DashboardShellProps) {
  const [currentNavId, setCurrentNavId] = React.useState(activeNavId)
  const [collapsed, setCollapsed] = React.useState(false)
  const [mobileOpen, setMobileOpen] = React.useState(false)

  function handleNav(item: NavItem) {
    setCurrentNavId(item.id)
    setMobileOpen(false)
    onNavItemClick?.(item)
  }

  const sidebarShared = {
    sections: navSections,
    activeNavId: currentNavId,
    brandName,
    brandIcon,
    onNavItemClick: handleNav,
  }

  return (
    <div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
      {/* ── Desktop sidebar ── */}
      <aside
        className={cn(
          "hidden lg:flex shrink-0 flex-col border-r border-border bg-card transition-all duration-200",
          collapsed ? "w-14" : "w-60"
        )}
      >
        <Sidebar {...sidebarShared} collapsed={collapsed} />

        {/* Collapse toggle */}
        <div className="shrink-0 border-t border-border p-2">
          <TooltipProvider delayDuration={150}>
            <Tooltip>
              <TooltipTrigger asChild>
                <Button
                  variant="ghost"
                  size="sm"
                  onClick={() => setCollapsed((c) => !c)}
                  className={cn(
                    "w-full justify-start gap-2 text-muted-foreground text-xs",
                    collapsed && "justify-center"
                  )}
                >
                  <Icon.ChevronLeft
                    className={cn("size-4 transition-transform duration-200", collapsed && "rotate-180")}
                  />
                  {!collapsed && "Collapse"}
                </Button>
              </TooltipTrigger>
              {collapsed && <TooltipContent side="right">Expand sidebar</TooltipContent>}
            </Tooltip>
          </TooltipProvider>
        </div>
      </aside>

      {/* ── Mobile sidebar (Sheet) ── */}
      <Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
        <SheetContent side="left" className="w-60 p-0 border-r">
          <Sidebar {...sidebarShared} collapsed={false} />
        </SheetContent>
      </Sheet>

      {/* ── Main column ── */}
      <div className="flex flex-1 flex-col overflow-hidden">
        <Header
          breadcrumbs={breadcrumbs}
          user={user}
          onUserAction={onUserAction}
          headerSlot={headerSlot}
          mobileTrigger={
            <Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
              <SheetTrigger asChild>
                <Button variant="ghost" size="icon" className="lg:hidden shrink-0" aria-label="Open navigation">
                  <Icon.Menu className="size-5" />
                </Button>
              </SheetTrigger>
            </Sheet>
          }
        />

        {/* Page content */}
        <main className="flex-1 overflow-auto">
          <ScrollArea className="h-full w-full">
            <div className="p-6">{children}</div>
          </ScrollArea>
        </main>
      </div>
    </div>
  )
}

// ---------------------------------------------------------------------------
// DashboardShellDemo — standalone preview / Storybook story
// ---------------------------------------------------------------------------

export function DashboardShellDemo() {
  return (
    <DashboardShell>
      <div className="space-y-6">
        <div>
          <h1 className="text-2xl font-bold tracking-tight">Overview</h1>
          <p className="text-muted-foreground mt-1">Welcome back to the Andy Code platform.</p>
        </div>
        <Separator />
        <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
          {[
            { label: "Active Agents", value: "3", sub: "+1 from yesterday" },
            { label: "Tasks Completed", value: "142", sub: "Last 24 hours" },
            { label: "Outputs Created", value: "28", sub: "This week" },
            { label: "Skills Active", value: "11", sub: "Across all agents" },
          ].map((stat) => (
            <div key={stat.label} className="rounded-xl border border-border bg-card p-5 shadow-sm">
              <p className="text-sm text-muted-foreground">{stat.label}</p>
              <p className="mt-1 text-3xl font-bold tracking-tight">{stat.value}</p>
              <p className="mt-1 text-xs text-muted-foreground">{stat.sub}</p>
            </div>
          ))}
        </div>
        <div className="rounded-xl border border-border bg-card p-6 shadow-sm">
          <p className="text-sm text-muted-foreground">
            Drop your page content here. The shell handles navigation, breadcrumbs,
            user menu, responsive sidebar, and scroll management.
          </p>
        </div>
      </div>
    </DashboardShell>
  )
}