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-shellPreview
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>
)
}