# App Shell 06

> A nested app sidebar for shadcn/ui. Open a project or settings and the whole sidebar slides to its own links, like the Vercel dashboard, instead of expanding a dropdown. A back row slides out again, the breadcrumb follows the path, and multi-level menus go as deep as you need. Built from sidebar tokens, so it takes on your theme.

A shadcn/ui block from the [@hirael](https://hirael.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/hirael/app-shell-06).

- Page, with a live preview: https://shadcn.exchange/r/hirael/app-shell-06
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/hirael/app-shell-06-light-b4429ecd3738112b.png
- Registry item: https://hirael.com/r/app-shell-06.json
- Categories: blocks, app-shell
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

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

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `breadcrumb`, `direction`, `separator`, `sidebar`

## Files

### `registry/hirael/bases/radix/blocks/app-shell-06/app-shell-06.tsx`

```tsx
// App Shell 06 from Hirael <https://hirael.com/blocks/app-shell/app-shell-06>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

'use client';

import * as React from 'react';
import {
  Activity,
  AppWindow,
  ArrowLeft,
  BarChart3,
  BookOpen,
  ChevronRight,
  CreditCard,
  GitBranch,
  Globe,
  KeyRound,
  LayoutDashboard,
  Rocket,
  ScrollText,
  Server,
  Settings,
  Users,
  type LucideIcon,
} from 'lucide-react';

import { cn } from '@/lib/utils';
import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from '@/registry/hirael/bases/radix/ui/breadcrumb';
import { useDirection } from '@/registry/hirael/bases/radix/ui/direction';
import { Separator } from '@/registry/hirael/bases/radix/ui/separator';
import {
  Sidebar,
  SidebarContent,
  SidebarGroup,
  SidebarGroupContent,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInset,
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
  SidebarRail,
  SidebarTrigger,
} from '@/registry/hirael/bases/radix/ui/sidebar';

interface NavGroup {
  label?: string;
  items: readonly NavNode[];
}

/** A sidebar entry. One with `groups` replaces the whole list instead of opening a page. */
interface NavNode {
  id: string;
  label: string;
  icon: LucideIcon;
  groups?: readonly NavGroup[];
}

type Direction = 'none' | 'forward' | 'back';

const WORKSPACE = 'Hirael';

interface BrandMarkProps {
  className?: string;
}

const BrandMark = ({ className }: BrandMarkProps) => {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={className}>
      <path d="M2.3 12h2.4v10.95h6.2V14.6h4.6v8.35h6.2V12h-2.4V1.05h-6.2V9.4H8.5V1.05H2.3Z" />
    </svg>
  );
};

const projectNode = (name: string, label: string, icon: LucideIcon): NavNode => ({
  id: name,
  label,
  icon,
  groups: [
    {
      items: [
        { id: `${name}/overview`, label: 'Overview', icon: LayoutDashboard },
        { id: `${name}/deployments`, label: 'Deployments', icon: Rocket },
        { id: `${name}/logs`, label: 'Logs', icon: ScrollText },
        { id: `${name}/analytics`, label: 'Analytics', icon: BarChart3 },
        {
          id: `${name}/settings`,
          label: 'Settings',
          icon: Settings,
          groups: [
            {
              label: 'Project settings',
              items: [
                { id: `${name}/settings/general`, label: 'General', icon: Settings },
                { id: `${name}/settings/domains`, label: 'Domains', icon: Globe },
                { id: `${name}/settings/environment`, label: 'Environment variables', icon: KeyRound },
                { id: `${name}/settings/git`, label: 'Git', icon: GitBranch },
              ],
            },
          ],
        },
      ],
    },
  ],
});

const ROOT: readonly NavGroup[] = [
  {
    items: [
      { id: 'overview', label: 'Overview', icon: LayoutDashboard },
      { id: 'activity', label: 'Activity', icon: Activity },
      {
        id: 'settings',
        label: 'Settings',
        icon: Settings,
        groups: [
          {
            label: 'Workspace settings',
            items: [
              { id: 'settings/general', label: 'General', icon: Settings },
              { id: 'settings/members', label: 'Members', icon: Users },
              { id: 'settings/billing', label: 'Billing', icon: CreditCard },
            ],
          },
        ],
      },
    ],
  },
  {
    label: 'Projects',
    items: [
      projectNode('website', 'Website', AppWindow),
      projectNode('docs', 'Documentation', BookOpen),
      projectNode('api', 'API', Server),
    ],
  },
];

const itemsOf = (groups: readonly NavGroup[]) => groups.flatMap((group) => group.items);

// tw-animate's from-start/end only read dir on the element itself, so the rtl: variant flips the slide instead.
const SLIDE: Record<Direction, string> = {
  none: '',
  forward:
    'animate-in fade-in slide-in-from-right-8 rtl:slide-in-from-left-8 duration-300 ease-out motion-reduce:animate-none',
  back: 'animate-in fade-in slide-in-from-left-8 rtl:slide-in-from-right-8 duration-300 ease-out motion-reduce:animate-none',
};

const AppShell06 = () => {
  const [stack, setStack] = React.useState<NavNode[]>([]);
  const [activeId, setActiveId] = React.useState('overview');
  const [direction, setDirection] = React.useState<Direction>('none');

  const current = stack.at(-1);
  const groups = current?.groups ?? ROOT;
  const parentLabel = stack.length > 1 ? stack[stack.length - 2].label : WORKSPACE;
  const activeLabel = itemsOf(groups).find((item) => item.id === activeId)?.label ?? '';

  const handleOpen = (node: NavNode) => {
    if (!node.groups) {
      setActiveId(node.id);

      return;
    }
    setDirection('forward');
    setStack([...stack, node]);
    setActiveId(itemsOf(node.groups).find((item) => !item.groups)?.id ?? '');
  };

  // Going back lands on the entry you drilled out of, so the list you return to shows where you were.
  const handleBack = (depth: number) => {
    const leaving = stack[depth];
    if (!leaving) return;
    setDirection('back');
    setStack(stack.slice(0, depth));
    setActiveId(leaving.id);
  };

  // Sidebar's `side` is physical, so an RTL page pins it to the right.
  const sidebarSide = useDirection() === 'rtl' ? 'right' : 'left';

  return (
    <SidebarProvider>
      <Sidebar side={sidebarSide} variant="inset" collapsible="icon">
        <SidebarHeader>
          <SidebarMenu>
            <SidebarMenuItem>
              <SidebarMenuButton size="lg" tooltip={WORKSPACE} onClick={() => handleBack(0)}>
                <span className="flex aspect-square size-8 shrink-0 items-center justify-center rounded-sm text-sidebar-foreground">
                  <BrandMark className="size-5" />
                </span>
                <span className="grid min-w-0 flex-1 text-start leading-tight">
                  <span className="truncate font-semibold">{WORKSPACE}</span>
                  <span className="truncate text-xs text-sidebar-foreground/70">Workspace</span>
                </span>
              </SidebarMenuButton>
            </SidebarMenuItem>
          </SidebarMenu>
        </SidebarHeader>

        <SidebarContent className="overflow-x-hidden">
          {/* Keyed by the open path so each list mounts fresh and plays its slide instead of updating in place. */}
          <div key={current?.id ?? 'root'} data-slot="app-shell-nav" className={cn('flex flex-col', SLIDE[direction])}>
            {current && (
              <SidebarGroup>
                <SidebarMenu>
                  <SidebarMenuItem>
                    <SidebarMenuButton tooltip={`Back to ${parentLabel}`} onClick={() => handleBack(stack.length - 1)}>
                      <ArrowLeft className="rtl:rotate-180" />
                      <span className="truncate">{parentLabel}</span>
                    </SidebarMenuButton>
                  </SidebarMenuItem>
                </SidebarMenu>
              </SidebarGroup>
            )}
            {groups.map((group, index) => (
              <SidebarGroup key={group.label ?? index}>
                {group.label && <SidebarGroupLabel>{group.label}</SidebarGroupLabel>}
                <SidebarGroupContent>
                  <SidebarMenu>
                    {group.items.map((item) => (
                      <SidebarMenuItem key={item.id}>
                        <SidebarMenuButton
                          isActive={item.id === activeId}
                          aria-current={item.id === activeId ? 'page' : undefined}
                          tooltip={item.label}
                          onClick={() => handleOpen(item)}
                        >
                          <item.icon />
                          <span className="truncate">{item.label}</span>
                          {item.groups && <ChevronRight className="ms-auto rtl:rotate-180" />}
                        </SidebarMenuButton>
                      </SidebarMenuItem>
                    ))}
                  </SidebarMenu>
                </SidebarGroupContent>
              </SidebarGroup>
            ))}
          </div>
        </SidebarContent>
        <SidebarRail />
      </Sidebar>

      <SidebarInset className="min-w-0">
        <header className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-4">
          <SidebarTrigger className="-ms-1" />
          <Separator orientation="vertical" className="mx-1 data-[orientation=vertical]:h-5" />
          <Breadcrumb className="min-w-0">
            <BreadcrumbList className="flex-nowrap">
              <BreadcrumbItem className="hidden sm:inline-flex">
                <BreadcrumbLink
                  href="#"
                  onClick={(event) => {
                    event.preventDefault();
                    handleBack(0);
                  }}
                >
                  {WORKSPACE}
                </BreadcrumbLink>
              </BreadcrumbItem>
              {stack.map((node, depth) => (
                <React.Fragment key={node.id}>
                  <BreadcrumbSeparator className="hidden sm:block" />
                  <BreadcrumbItem className="hidden sm:inline-flex">
                    <BreadcrumbLink
                      href="#"
                      onClick={(event) => {
                        event.preventDefault();
                        handleBack(depth + 1);
                      }}
                    >
                      {node.label}
                    </BreadcrumbLink>
                  </BreadcrumbItem>
                </React.Fragment>
              ))}
              <BreadcrumbSeparator className="hidden sm:block" />
              <BreadcrumbItem>
                <BreadcrumbPage>{activeLabel}</BreadcrumbPage>
              </BreadcrumbItem>
            </BreadcrumbList>
          </Breadcrumb>
        </header>
        <main data-slot="app-shell-main" className="flex min-w-0 flex-1 flex-col gap-4 p-4 sm:p-6">
          <div className="flex flex-col gap-1">
            <h1 className="text-2xl font-semibold tracking-tight">{activeLabel}</h1>
            <p className="text-sm text-muted-foreground">Items with an arrow open their own list in the sidebar.</p>
          </div>
          <div className="grid gap-4 sm:grid-cols-3">
            <div className="aspect-video rounded-xl bg-muted/50" />
            <div className="aspect-video rounded-xl bg-muted/50" />
            <div className="aspect-video rounded-xl bg-muted/50" />
          </div>
          <div className="min-h-64 flex-1 rounded-xl bg-muted/50" />
        </main>
      </SidebarInset>
    </SidebarProvider>
  );
};

export default AppShell06;
```
