Skip to content
6

App Shell 4

Starter shell on the shadcn Sidebar primitive (inset variant): a workspace switcher that switches, a ⌘K field that filters the nav, icon-collapsible items with tooltips, an inset header with an account menu and notification count, and a welcome heading over dashed placeholder slots.

Preview

Installation

npx shadcn@latest add https://hirael.com/r/app-shell-04.json

Code

components/blocks/app-shell-04.tsx
'use client';

import * as React from 'react';
import {
  Bell,
  CalendarRange,
  Check,
  ChartNoAxesColumn,
  ChevronsUpDown,
  LayoutDashboard,
  LifeBuoy,
  LogOut,
  Megaphone,
  Plug,
  Search,
  Settings,
  Users,
  type LucideIcon,
} from 'lucide-react';

import { cn } from '@/lib/utils';
import { Badge } from '@/registry/hirael/bases/radix/ui/badge';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from '@/registry/hirael/bases/radix/ui/dropdown-menu';
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/registry/hirael/bases/radix/ui/input-group';
import { KbdDisplay, KbdGroup } from '@/registry/hirael/bases/radix/components/kbd';
import { Separator } from '@/registry/hirael/bases/radix/ui/separator';
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupContent,
  SidebarHeader,
  SidebarInset,
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
  SidebarRail,
  SidebarTrigger,
} from '@/registry/hirael/bases/radix/ui/sidebar';

interface NavItem {
  icon: LucideIcon;
  label: string;
}

const NAV: readonly NavItem[] = [
  { icon: LayoutDashboard, label: 'Overview' },
  { icon: CalendarRange, label: 'Planner' },
  { icon: Megaphone, label: 'Campaigns' },
  { icon: ChartNoAxesColumn, label: 'Reports' },
  { icon: Users, label: 'Audience' },
  { icon: Plug, label: 'Connections' },
];

const FOOTER_NAV: readonly NavItem[] = [
  { icon: LifeBuoy, label: 'Support' },
  { icon: Settings, label: 'Settings' },
];

interface Workspace {
  name: string;
  tier: string;
}

const WORKSPACES: readonly Workspace[] = [
  { name: 'Plinth Labs', tier: 'Pro workspace' },
  { name: 'Northbeam', tier: 'Team workspace' },
  { name: 'Personal', tier: 'Free workspace' },
];

const SLOTS = [
  { label: 'slot · 01', className: 'min-h-28' },
  { label: 'slot · 02', className: 'min-h-28' },
  { label: 'slot · 03', className: 'min-h-28' },
  { label: 'slot · 04', className: 'min-h-28' },
  { label: 'slot · 05', className: 'col-span-2 min-h-56 md:col-span-3' },
  { label: 'slot · 06', className: 'col-span-2 min-h-56 md:col-span-1' },
  { label: 'slot · 07', className: 'col-span-2 min-h-56 md:col-span-1' },
  { label: 'slot · 08', className: 'col-span-2 min-h-56 md:col-span-3' },
] as const;

const BrandMark = ({ className }: { className?: string }) => {
  return (
    <svg
      viewBox="0 0 80 100"
      fill="none"
      stroke="currentColor"
      strokeWidth="2.2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden
      className={className}
    >
      <path d="M16 78 V40 a24 24 0 0 1 48 0 V78" />
      <path d="M40 44 L43.2 52 L51 55 L43.2 58 L40 66 L36.8 58 L29 55 L36.8 52 Z" />
      <path d="M22 86 H58" opacity="0.7" />
      <path d="M28 92 H52" opacity="0.45" />
      <path d="M34 96 H46" opacity="0.25" />
    </svg>
  );
};

const Slot = ({ label, className }: { label: string; className?: string }) => {
  return (
    <div
      aria-hidden
      className={cn(
        'flex items-center justify-center rounded-md border border-dashed border-border/80 bg-muted/30',
        className,
      )}
    >
      <span className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">{label}</span>
    </div>
  );
};

const AppShell04 = () => {
  const [workspace, setWorkspace] = React.useState(WORKSPACES[0].name);
  const [active, setActive] = React.useState(NAV[0].label);
  const [query, setQuery] = React.useState('');
  const searchRef = React.useRef<HTMLInputElement>(null);

  // The ⌘K hint next to the field has to focus something, or it is a sticker.
  React.useEffect(() => {
    const onKeyDown = (event: KeyboardEvent) => {
      const isSearchShortcut = event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey);
      if (!isSearchShortcut) return;
      event.preventDefault();
      searchRef.current?.focus();
      searchRef.current?.select();
    };
    document.addEventListener('keydown', onKeyDown);
    return () => document.removeEventListener('keydown', onKeyDown);
  }, []);

  const normalized = query.trim().toLowerCase();
  const visibleNav = normalized ? NAV.filter((item) => item.label.toLowerCase().includes(normalized)) : NAV;

  const activeWorkspace = WORKSPACES.find((w) => w.name === workspace) ?? WORKSPACES[0];

  return (
    <SidebarProvider>
      <Sidebar variant="inset" collapsible="icon">
        <SidebarHeader>
          <SidebarMenu>
            <SidebarMenuItem>
              <DropdownMenu>
                <DropdownMenuTrigger asChild>
                  <SidebarMenuButton
                    size="lg"
                    tooltip={activeWorkspace.name}
                    className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
                  >
                    <span className="flex aspect-square size-8 shrink-0 items-center justify-center rounded-md bg-sidebar-foreground text-sidebar">
                      <BrandMark className="size-5" />
                    </span>
                    <div className="grid min-w-0 flex-1 text-start leading-tight">
                      <span className="truncate text-sm font-semibold tracking-[-0.01em]">{activeWorkspace.name}</span>
                      <span className="truncate font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground">
                        {activeWorkspace.tier}
                      </span>
                    </div>
                    <ChevronsUpDown className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
                  </SidebarMenuButton>
                </DropdownMenuTrigger>
                <DropdownMenuContent align="start" className="w-56">
                  <DropdownMenuLabel className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
                    Workspaces
                  </DropdownMenuLabel>
                  {WORKSPACES.map((w) => (
                    <DropdownMenuItem key={w.name} onSelect={() => setWorkspace(w.name)}>
                      <span className="flex-1">{w.name}</span>
                      {w.name === activeWorkspace.name && <Check className="size-4" aria-label="Current" />}
                    </DropdownMenuItem>
                  ))}
                </DropdownMenuContent>
              </DropdownMenu>
            </SidebarMenuItem>
          </SidebarMenu>

          <InputGroup className="h-8 group-data-[collapsible=icon]:hidden">
            <InputGroupAddon align="inline-start">
              <Search className="size-3.5" aria-hidden />
            </InputGroupAddon>
            <InputGroupInput
              ref={searchRef}
              type="search"
              value={query}
              onChange={(e) => setQuery(e.target.value)}
              onKeyDown={(e) => {
                if (e.key !== 'Escape') return;
                if (query) {
                  e.preventDefault();
                  setQuery('');
                  return;
                }
                e.currentTarget.blur();
              }}
              placeholder="Filter nav…"
              aria-label="Filter navigation"
              aria-keyshortcuts="Meta+K Control+K"
              className="text-sm"
            />
            <InputGroupAddon dir="ltr" align="inline-end">
              <KbdGroup>
                <KbdDisplay></KbdDisplay>
                <KbdDisplay>K</KbdDisplay>
              </KbdGroup>
            </InputGroupAddon>
          </InputGroup>
        </SidebarHeader>

        <SidebarContent>
          <SidebarGroup>
            <SidebarGroupContent>
              <SidebarMenu>
                {visibleNav.map((item) => (
                  <SidebarMenuItem key={item.label}>
                    <SidebarMenuButton
                      isActive={item.label === active}
                      tooltip={item.label}
                      onClick={() => setActive(item.label)}
                    >
                      <item.icon />
                      <span>{item.label}</span>
                    </SidebarMenuButton>
                  </SidebarMenuItem>
                ))}
              </SidebarMenu>
              {visibleNav.length === 0 && (
                <p className="px-2 py-3 text-xs text-muted-foreground group-data-[collapsible=icon]:hidden">
                  Nothing matches “{query.trim()}”.
                </p>
              )}
            </SidebarGroupContent>
          </SidebarGroup>
        </SidebarContent>

        <SidebarFooter>
          <SidebarMenu>
            {FOOTER_NAV.map((item) => (
              <SidebarMenuItem key={item.label}>
                <SidebarMenuButton asChild tooltip={item.label} className="h-7">
                  <a href="#">
                    <item.icon />
                    <span>{item.label}</span>
                  </a>
                </SidebarMenuButton>
              </SidebarMenuItem>
            ))}
          </SidebarMenu>
          <span className="px-2 pb-1 font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground group-data-[collapsible=icon]:hidden">
            © {activeWorkspace.name}
          </span>
        </SidebarFooter>

        <SidebarRail />
      </Sidebar>

      <SidebarInset className="min-h-[640px]">
        <header className="flex h-14 shrink-0 items-center justify-between gap-3 border-b border-border px-4">
          <div className="flex min-w-0 items-center gap-2">
            <SidebarTrigger />
            <Separator orientation="vertical" className="h-4" />
            <span className="truncate text-sm font-medium tracking-[-0.01em]">{active}</span>
          </div>
          <div className="flex shrink-0 items-center gap-2">
            <Button variant="ghost" size="icon" className="relative size-8" aria-label="Notifications · 3 unread">
              <Bell className="size-4" aria-hidden />
              <Badge
                aria-hidden
                className="absolute -end-1 -top-1 size-4 justify-center rounded-full p-0 font-mono text-[10px] tabular-nums"
              >
                3
              </Badge>
            </Button>
            <Separator orientation="vertical" className="h-4" />
            <DropdownMenu>
              <DropdownMenuTrigger asChild>
                <Button
                  variant="outline"
                  size="icon"
                  aria-label="Account menu"
                  className="size-8 rounded-full font-mono text-[11px] font-medium"
                >
                  MS
                </Button>
              </DropdownMenuTrigger>
              <DropdownMenuContent align="end" className="w-56">
                <DropdownMenuLabel className="font-normal">
                  <span className="block text-sm font-medium">Maya Renner</span>
                  <span className="block truncate text-xs text-muted-foreground">maya@plinth.dev</span>
                </DropdownMenuLabel>
                <DropdownMenuSeparator />
                <DropdownMenuItem>
                  <Settings />
                  Preferences
                </DropdownMenuItem>
                <DropdownMenuItem>
                  <LifeBuoy />
                  Support
                </DropdownMenuItem>
                <DropdownMenuSeparator />
                <DropdownMenuItem variant="destructive">
                  <LogOut />
                  Sign out
                </DropdownMenuItem>
              </DropdownMenuContent>
            </DropdownMenu>
          </div>
        </header>

        <div className="flex flex-1 flex-col gap-6 p-4 sm:p-6">
          <div className="flex flex-col gap-1">
            <h1 className="font-serif text-2xl font-medium tracking-tight sm:text-3xl">Good morning, Maya.</h1>
            <p className="text-sm text-muted-foreground">Your workspace is ready. Drop content into the slots below.</p>
          </div>

          <div className="grid flex-1 grid-cols-2 gap-4 md:grid-cols-4">
            {SLOTS.map((slot) => (
              <Slot key={slot.label} label={slot.label} className={slot.className} />
            ))}
          </div>
        </div>
      </SidebarInset>
    </SidebarProvider>
  );
};

export default AppShell04;

Dependencies

shadcn registry

badgebuttondropdown-menuinput-groupkbdseparatorsidebar

npm

lucide-react