odysseyui
componentcomponents-app-shells-app-shell-2source: ok · hit
App Shell 2
A floating sidebar app shell with collapsible icon mode, searchable navigation, keyboard shortcuts modal, and user menu.
npx shadcn add "https://www.odysseyui.com/r/components-app-shells-app-shell-2.json"free
Where it goes
sidebardashboard-shellkanban
Interaction
accordionmodaldropdown
Use cases
dashboard
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · light@hugeicons/react@hugeicons/core-free-icons
Registry dependencies
sidebarbreadcrumbdropdown-menuseparatordialogavatar@odysseyui/components-primitives-avatar
Files2 · source cached 10/9/2026, 12:05:34 AM
registry/components/app-shells/app-shell-2/index.tsx
'use client';
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarTrigger,
useSidebar,
} from '@/components/ui/sidebar';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Avatar as UIAvatar,
AvatarFallback,
AvatarImage,
} from '@/components/ui/avatar';
import { Separator } from '@/components/ui/separator';
import { HugeiconsIcon } from '@hugeicons/react';
import {
SearchIcon,
CommandLineIcon,
RobotIcon,
BookOpen01Icon,
Settings02Icon,
FramerIcon,
PieChartIcon,
MapsIcon,
ArrowRight01Icon,
Logout01Icon,
User02Icon,
UnfoldMoreIcon,
KeyboardIcon,
} from '@hugeicons/core-free-icons';
import OdysseyAvatar, {
type AvatarColor,
} from '@/components/odyssey/components/primitives/avatar';
import { cn } from '@/lib/utils';
import type { AppShell2Config, SidebarSection } from './app-shell-2.config';
import { appShell2Config as defaultConfig } from './app-shell-2.config';
// ─── Kbd ──────────────────────────────────────────────────────────────────────
function Kbd({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<kbd
className={cn(
'inline-flex h-5 min-w-5 items-center justify-center rounded border bg-muted px-1 text-[11px] font-medium text-muted-foreground',
className,
)}
>
{children}
</kbd>
);
}
// ─── Icon map ─────────────────────────────────────────────────────────────────
const iconMap: Record<string, any> = {
SquareTerminal: CommandLineIcon,
Bot: RobotIcon,
BookOpen: BookOpen01Icon,
Settings2: Settings02Icon,
Frame: FramerIcon,
PieChart: PieChartIcon,
Map: MapsIcon,
};
// ─── Avatar color helper ──────────────────────────────────────────────────────
const AVATAR_COLORS: AvatarColor[] = [
'blue',
'orange',
'red',
'green',
'purple',
'yellow',
'cyan',
'pink',
'indigo',
'lime',
'turquoise',
'violet',
];
function getAvatarColor(seed: string): AvatarColor {
const n = seed.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
return AVATAR_COLORS[n % AVATAR_COLORS.length];
}
// ─── Shortcuts Modal ──────────────────────────────────────────────────────────
const SHORTCUTS = [
{ label: 'Open search', keys: ['⌘', 'K'] },
{ label: 'Toggle sidebar', keys: ['⌘', 'B'] },
] as const;
function ShortcutsModal({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogHeader className="sr-only">
<DialogTitle>Keyboard shortcuts</DialogTitle>
<DialogDescription>Available keyboard shortcuts</DialogDescription>
</DialogHeader>
<DialogContent className="sm:max-w-xs">
<div className="mb-4 flex flex-col gap-1">
<p className="text-sm font-semibold">Keyboard shortcuts</p>
<p className="text-xs text-muted-foreground">
Navigate faster with these shortcuts
</p>
</div>
<div className="flex flex-col gap-3">
{SHORTCUTS.map((s) => (
<div key={s.label} className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{s.label}</span>
<span className="flex items-center gap-0.5">
{s.keys.map((k) => (
<Kbd key={k}>{k}</Kbd>
))}
</span>
</div>
))}
</div>
</DialogContent>
</Dialog>
);
}
// ─── Shared user avatar + menu ────────────────────────────────────────────────
function UserAvatar({
src,
name,
color,
}: {
src: string;
name: string;
color: AvatarColor;
}) {
return (
<UIAvatar className="size-8">
<AvatarImage src={src} alt={name} />
<AvatarFallback className="overflow-hidden bg-transparent p-0">
<OdysseyAvatar color={color} size="sm" />
</AvatarFallback>
</UIAvatar>
);
}
function UserMenuContent({
src,
name,
email,
color,
profileHref,
onLogout,
onShortcuts,
}: {
src: string;
name: string;
email: string;
color: AvatarColor;
profileHref: string;
onLogout?: () => void;
onShortcuts: () => void;
}) {
return (
<>
<DropdownMenuGroup>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<UserAvatar src={src} name={name} color={color} />
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{name}</span>
<span className="truncate text-xs text-muted-foreground">
{email}
</span>
</div>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link href={profileHref}>
<HugeiconsIcon icon={User02Icon} strokeWidth={2} />
Profile
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={onShortcuts}>
<HugeiconsIcon icon={KeyboardIcon} strokeWidth={2} />
Shortcuts
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={onLogout}>
<HugeiconsIcon icon={Logout01Icon} strokeWidth={2} />
Log out
</DropdownMenuItem>
</>
);
}
// ─── Types ────────────────────────────────────────────────────────────────────
export interface SidebarUserInfo {
name?: string;
email?: string;
avatar?: string;
}
export interface AppShell2SearchProps {
config: AppShell2Config;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export interface AppShell2PageHeaderProps {
pageName: string;
breadcrumbs?: Array<{ label: string; href?: string }>;
actions?: React.ReactNode;
user?: SidebarUserInfo;
profileHref?: string;
onLogout?: () => void;
}
export interface AppShell2Props {
config?: AppShell2Config;
collapsibleSubsections?: boolean;
user?: SidebarUserInfo;
profileHref?: string;
onLogout?: () => void;
}
// ─── Search Modal ─────────────────────────────────────────────────────────────
export function AppShell2Search({
config,
open,
onOpenChange,
}: AppShell2SearchProps) {
const router = useRouter();
const [query, setQuery] = useState('');
const filtered = useMemo(() => {
if (!query.trim()) return config.sections;
const q = query.toLowerCase();
return config.sections
.map((section) => ({
...section,
items: section.items.filter(
(item) =>
item.title.toLowerCase().includes(q) ||
item.keywords?.some((kw) => kw.toLowerCase().includes(q)) ||
section.label.toLowerCase().includes(q),
),
}))
.filter((section) => section.items.length > 0);
}, [config.sections, query]);
const runCommand = useCallback(
(command: () => unknown) => {
onOpenChange(false);
setQuery('');
command();
},
[onOpenChange],
);
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
if (
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
)
return;
e.preventDefault();
onOpenChange(!open);
}
};
document.addEventListener('keydown', down);
return () => document.removeEventListener('keydown', down);
}, [open, onOpenChange]);
useEffect(() => {
if (!open) setQuery('');
}, [open]);
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogHeader className="sr-only">
<DialogTitle>Search pages</DialogTitle>
<DialogDescription>Navigate to a page quickly</DialogDescription>
</DialogHeader>
<DialogContent
className="top-1/4 translate-y-0 gap-0 overflow-hidden rounded-xl border-none p-0 shadow-2xl sm:max-w-xl"
showCloseButton={false}
>
<div className="flex items-center border-b px-3">
<HugeiconsIcon
icon={SearchIcon}
strokeWidth={2}
className="mr-2 size-4 shrink-0 opacity-50"
/>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search pages..."
className="flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div className="relative max-h-72 overflow-y-auto">
{filtered.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground">
No results found.
</p>
) : (
filtered.map((section) => (
<div key={section.label} className="overflow-hidden p-1">
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{section.label}
</div>
{section.items.map((item) => (
<button
key={item.href}
onClick={() => runCommand(() => router.push(item.href))}
className="relative flex w-full cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground"
>
<HugeiconsIcon
icon={ArrowRight01Icon}
strokeWidth={2}
className="size-4"
/>
{item.title}
</button>
))}
</div>
))
)}
</div>
<div className="flex h-9 items-center justify-between border-t px-4 text-xs text-muted-foreground">
<div className="flex gap-4">
<span className="flex items-center gap-1.5">
<Kbd>↩</Kbd>
<span>Select</span>
</span>
<span className="flex items-center gap-1.5">
<Kbd>↑↓</Kbd>
<span>Navigate</span>
</span>
</div>
<span className="flex items-center gap-1.5">
<Kbd>Esc</Kbd>
<span>Close</span>
</span>
</div>
</DialogContent>
</Dialog>
);
}
// ─── Page Header ──────────────────────────────────────────────────────────────
export function AppShell2PageHeader({
pageName,
breadcrumbs,
actions,
user,
profileHref = '/profile',
onLogout,
}: AppShell2PageHeaderProps) {
const userName = user?.name || 'User';
const userEmail = user?.email || 'user@example.com';
const avatarSrc = user?.avatar || '';
const avatarColor = getAvatarColor(userName);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
return (
<>
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
<div className="flex flex-1 items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mr-2 data-[orientation=vertical]:h-4"
/>
<Breadcrumb>
<BreadcrumbList>
{breadcrumbs && breadcrumbs.length > 0 ? (
breadcrumbs.map((crumb, index) => {
const isLast = index === breadcrumbs.length - 1;
return (
<React.Fragment key={index}>
<BreadcrumbItem>
{!isLast && crumb.href ? (
<BreadcrumbLink href={crumb.href}>
{crumb.label}
</BreadcrumbLink>
) : (
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
)}
</BreadcrumbItem>
{!isLast && <BreadcrumbSeparator />}
</React.Fragment>
);
})
) : (
<BreadcrumbItem>
<BreadcrumbPage>{pageName}</BreadcrumbPage>
</BreadcrumbItem>
)}
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="flex items-center gap-2">
{actions}
<DropdownMenu>
<DropdownMenuTrigger className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring">
<UserAvatar src={avatarSrc} name={userName} color={avatarColor} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" sideOffset={4}>
<UserMenuContent
src={avatarSrc}
name={userName}
email={userEmail}
color={avatarColor}
profileHref={profileHref}
onLogout={onLogout}
onShortcuts={() => setShortcutsOpen(true)}
/>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<ShortcutsModal open={shortcutsOpen} onOpenChange={setShortcutsOpen} />
</>
);
}
// ─── Nav User (sidebar footer) ────────────────────────────────────────────────
function SidebarNavUser({
user,
profileHref = '/profile',
onLogout,
}: {
user?: SidebarUserInfo;
profileHref?: string;
onLogout?: () => void;
}) {
const { isMobile } = useSidebar();
const userName = user?.name || 'User';
const userEmail = user?.email || 'user@example.com';
const avatarSrc = user?.avatar || '';
const avatarColor = getAvatarColor(userName);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
return (
<>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="aria-expanded:bg-sidebar-accent"
>
<UserAvatar
src={avatarSrc}
name={userName}
color={avatarColor}
/>
<div className="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
<span className="truncate font-medium">{userName}</span>
<span className="truncate text-xs text-muted-foreground">
{userEmail}
</span>
</div>
<HugeiconsIcon
icon={UnfoldMoreIcon}
strokeWidth={2}
className="ml-auto size-4 group-data-[collapsible=icon]:hidden"
/>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
side={isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
<UserMenuContent
src={avatarSrc}
name={userName}
email={userEmail}
color={avatarColor}
profileHref={profileHref}
onLogout={onLogout}
onShortcuts={() => setShortcutsOpen(true)}
/>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
<ShortcutsModal open={shortcutsOpen} onOpenChange={setShortcutsOpen} />
</>
);
}
// ─── Section Group ────────────────────────────────────────────────────────────
function SidebarSectionGroup({
section,
pathname,
collapsible,
}: {
section: SidebarSection;
pathname: string;
collapsible: boolean;
}) {
const [open, setOpen] = useState(true);
return (
<SidebarGroup>
<SidebarGroupLabel
className={cn(collapsible && 'cursor-pointer select-none')}
onClick={collapsible ? () => setOpen((v) => !v) : undefined}
>
<span className="flex-1">{section.label}</span>
{collapsible && (
<HugeiconsIcon
icon={ArrowRight01Icon}
strokeWidth={2}
className={cn(
'size-3.5 shrink-0 text-sidebar-foreground/50 transition-transform duration-200',
open && 'rotate-90',
)}
/>
)}
</SidebarGroupLabel>
<div
className={cn(
'grid transition-[grid-template-rows] duration-200 ease-in-out',
open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
)}
>
<div className="overflow-hidden">
<SidebarGroupContent>
<SidebarMenu>
{section.items.map((item) => {
const isActive = pathname === item.href;
const Icon = iconMap[item.icon];
return (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton
asChild
isActive={isActive}
tooltip={item.title}
>
<Link href={item.href}>
{Icon && (
<HugeiconsIcon
icon={Icon}
strokeWidth={2}
className="size-4 shrink-0"
/>
)}
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</div>
</div>
</SidebarGroup>
);
}
// ─── Main Sidebar ─────────────────────────────────────────────────────────────
export function AppShell2({
config = defaultConfig,
collapsibleSubsections = false,
user,
profileHref = '/profile',
onLogout,
}: AppShell2Props) {
const pathname = usePathname();
const [searchOpen, setSearchOpen] = useState(false);
const { state } = useSidebar();
const isCollapsed = state === 'collapsed';
return (
<>
<Sidebar collapsible="icon" variant="floating">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
{config.company.logo}
</div>
<div className="flex flex-col gap-0.5 leading-none group-data-[collapsible=icon]:hidden">
<span className="text-sm font-semibold">
{config.company.name}
</span>
<span className="text-xs text-muted-foreground">
{config.company.subtitle}
</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => setSearchOpen(true)}
variant="outline"
tooltip="Search"
className="border text-muted-foreground"
>
<HugeiconsIcon
icon={SearchIcon}
strokeWidth={2}
className="size-4 shrink-0"
/>
<span className="flex-1 group-data-[collapsible=icon]:hidden">
Search
</span>
{!isCollapsed && (
<span className="ml-auto flex items-center gap-0.5 group-data-[collapsible=icon]:hidden">
<Kbd>⌘</Kbd>
<Kbd>K</Kbd>
</span>
)}
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<div className="relative flex min-h-0 flex-1 overflow-hidden">
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-6 bg-linear-to-b from-sidebar to-transparent" />
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-6 bg-linear-to-t from-sidebar to-transparent" />
<SidebarContent>
{config.sections.map((section) => (
<SidebarSectionGroup
key={section.label}
section={section}
pathname={pathname}
collapsible={collapsibleSubsections}
/>
))}
</SidebarContent>
</div>
<SidebarFooter>
<SidebarNavUser
user={user}
profileHref={profileHref}
onLogout={onLogout}
/>
</SidebarFooter>
</Sidebar>
<AppShell2Search
config={config}
open={searchOpen}
onOpenChange={setSearchOpen}
/>
</>
);
}
export default AppShell2;
registry/components/app-shells/app-shell-2/app-shell-2.config.ts
export interface SidebarMenuItem {
title: string;
href: string;
icon: string;
keywords?: string[];
}
export interface SidebarSection {
label: string;
items: SidebarMenuItem[];
}
export interface SidebarCompany {
name: string;
subtitle: string;
logo?: string;
}
export interface AppShell2Config {
company: SidebarCompany;
sections: SidebarSection[];
}
export const appShell2Config: AppShell2Config = {
company: {
name: 'Acme Inc',
subtitle: 'Enterprise',
logo: '',
},
sections: [
{
label: 'Platform',
items: [
{
title: 'Playground',
href: '/playground',
icon: 'SquareTerminal',
keywords: ['playground', 'terminal', 'code'],
},
{
title: 'Models',
href: '/models',
icon: 'Bot',
keywords: ['models', 'ai', 'machine learning'],
},
{
title: 'Documentation',
href: '/documentation',
icon: 'BookOpen',
keywords: ['docs', 'documentation', 'guides'],
},
{
title: 'Settings',
href: '/settings',
icon: 'Settings2',
keywords: ['settings', 'preferences', 'config'],
},
],
},
{
label: 'Projects',
items: [
{
title: 'Design Engineering',
href: '/projects/design-engineering',
icon: 'Frame',
keywords: ['design', 'engineering', 'ui'],
},
{
title: 'Sales & Marketing',
href: '/projects/sales-marketing',
icon: 'PieChart',
keywords: ['sales', 'marketing', 'analytics'],
},
{
title: 'Travel',
href: '/projects/travel',
icon: 'Map',
keywords: ['travel', 'trips', 'locations'],
},
],
},
{
label: 'Platform 2',
items: [
{
title: 'Playground',
href: '/playground',
icon: 'SquareTerminal',
keywords: ['playground', 'terminal', 'code'],
},
{
title: 'Models',
href: '/models',
icon: 'Bot',
keywords: ['models', 'ai', 'machine learning'],
},
{
title: 'Documentation',
href: '/documentation',
icon: 'BookOpen',
keywords: ['docs', 'documentation', 'guides'],
},
{
title: 'Settings',
href: '/settings',
icon: 'Settings2',
keywords: ['settings', 'preferences', 'config'],
},
],
},
{
label: 'Projects 2',
items: [
{
title: 'Design Engineering',
href: '/projects/design-engineering',
icon: 'Frame',
keywords: ['design', 'engineering', 'ui'],
},
{
title: 'Sales & Marketing',
href: '/projects/sales-marketing',
icon: 'PieChart',
keywords: ['sales', 'marketing', 'analytics'],
},
{
title: 'Travel',
href: '/projects/travel',
icon: 'Map',
keywords: ['travel', 'trips', 'locations'],
},
],
},
{
label: 'Platform 3',
items: [
{
title: 'Playground',
href: '/playground',
icon: 'SquareTerminal',
keywords: ['playground', 'terminal', 'code'],
},
{
title: 'Models',
href: '/models',
icon: 'Bot',
keywords: ['models', 'ai', 'machine learning'],
},
{
title: 'Documentation',
href: '/documentation',
icon: 'BookOpen',
keywords: ['docs', 'documentation', 'guides'],
},
{
title: 'Settings',
href: '/settings',
icon: 'Settings2',
keywords: ['settings', 'preferences', 'config'],
},
],
},
{
label: 'Projects 3',
items: [
{
title: 'Design Engineering',
href: '/projects/design-engineering',
icon: 'Frame',
keywords: ['design', 'engineering', 'ui'],
},
{
title: 'Sales & Marketing',
href: '/projects/sales-marketing',
icon: 'PieChart',
keywords: ['sales', 'marketing', 'analytics'],
},
{
title: 'Travel',
href: '/projects/travel',
icon: 'Map',
keywords: ['travel', 'trips', 'locations'],
},
],
},
],
};