# App Shell 01

> Drop-in admin shell built on the shadcn Sidebar primitive: collapsible icon rail with nav badges and an account menu in the footer, sticky topbar with breadcrumb and a ⌘K search that really focuses, plus an accounts table that filters live and sorts by any column with aria-sort.

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

- Page, with a live preview: https://shadcn.exchange/r/hirael/app-shell-01
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/hirael/app-shell-01-light-5afbb0afdbaea79d.png
- Registry item: https://hirael.com/r/app-shell-01.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-01.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: `badge`, `button`, `card`, `direction`, `dropdown-menu`, `empty`, `input-group`, `kbd`, `separator`, `sidebar`, `table`

## Files

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

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

'use client';

import * as React from 'react';
import {
  ArrowDown,
  ArrowUp,
  Bell,
  ChevronRight,
  ChevronsUpDown,
  Command,
  Compass,
  CreditCard,
  Inbox,
  LayoutDashboard,
  LifeBuoy,
  LogOut,
  MoreHorizontal,
  Plus,
  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 { Card, CardContent } from '@/registry/hirael/bases/radix/ui/card';
import { useDirection } from '@/registry/hirael/bases/radix/ui/direction';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from '@/registry/hirael/bases/radix/ui/dropdown-menu';
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from '@/registry/hirael/bases/radix/ui/empty';
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/registry/hirael/bases/radix/ui/input-group';
import { Kbd } from '@/registry/hirael/bases/radix/ui/kbd';
import { Separator } from '@/registry/hirael/bases/radix/ui/separator';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/registry/hirael/bases/radix/ui/table';
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupContent,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInset,
  SidebarMenu,
  SidebarMenuBadge,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
  SidebarRail,
  SidebarSeparator,
  SidebarTrigger,
} from '@/registry/hirael/bases/radix/ui/sidebar';

const ENTER =
  'animate-in fade-in slide-in-from-bottom-2 duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] fill-mode-both motion-reduce:animate-none';
const SWAP =
  'animate-in fade-in zoom-in-97 duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] fill-mode-both motion-reduce:animate-none';

interface NavLink {
  label: string;
  icon: LucideIcon;
  href: string;
  active?: boolean;
  badge?: string;
}

const PRIMARY: readonly NavLink[] = [
  { label: 'Dashboard', icon: LayoutDashboard, href: '#', active: true },
  { label: 'Inbox', icon: Inbox, href: '#', badge: '3' },
  { label: 'Customers', icon: Users, href: '#' },
  { label: 'Billing', icon: CreditCard, href: '#' },
  { label: 'Explore', icon: Compass, href: '#' },
];

const SECONDARY: readonly NavLink[] = [
  { label: 'Settings', icon: Settings, href: '#' },
  { label: 'Support', icon: LifeBuoy, href: '#' },
];

type Plan = 'Hobby' | 'Pro' | 'Team';
type Status = 'Active' | 'Trial' | 'Past due';

interface Account {
  name: string;
  plan: Plan;
  mrr: number;
  status: Status;
  initials: string;
}

const ROWS: readonly Account[] = [
  {
    name: 'Hirael Labs',
    plan: 'Pro',
    mrr: 2480,
    status: 'Active',
    initials: 'HL',
  },
  { name: 'Hirael Studio', plan: 'Team', mrr: 6120, status: 'Active', initials: 'HS' },
  { name: 'Hirael Cloud', plan: 'Hobby', mrr: 0, status: 'Trial', initials: 'HC' },
  {
    name: 'Hirael Docs',
    plan: 'Pro',
    mrr: 1860,
    status: 'Past due',
    initials: 'HD',
  },
  {
    name: 'Hirael',
    plan: 'Team',
    mrr: 5400,
    status: 'Active',
    initials: 'HR',
  },
];

const PLAN_RANK: Record<Plan, number> = { Hobby: 0, Pro: 1, Team: 2 };
const STATUS_RANK: Record<Status, number> = {
  Active: 0,
  Trial: 1,
  'Past due': 2,
};

const STATUS_TONE: Record<Status, { dot: string; text: string }> = {
  Active: { dot: 'bg-success', text: 'text-success' },
  Trial: { dot: 'bg-warning', text: 'text-warning' },
  'Past due': { dot: 'bg-destructive', text: 'text-destructive' },
};

interface Metric {
  label: string;
  value: string;
  delta: number;
  unit: '%' | 'pt';
  goodWhen: 'up' | 'down';
}

const METRICS: readonly Metric[] = [
  { label: 'Customers', value: '1,284', delta: 4.1, unit: '%', goodWhen: 'up' },
  { label: 'MRR', value: '$48.2k', delta: 8.7, unit: '%', goodWhen: 'up' },
  { label: 'Churn', value: '1.8%', delta: -0.4, unit: '%', goodWhen: 'down' },
  { label: 'NPS', value: '62', delta: 2, unit: 'pt', goodWhen: 'up' },
];

const usd = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD',
  maximumFractionDigits: 0,
});

type SortKey = 'name' | 'plan' | 'mrr' | 'status';
type SortDirection = 'asc' | 'desc';

const COLUMNS: readonly {
  key: SortKey;
  label: string;
  align: 'start' | 'end';
  hideBelowSm?: boolean;
}[] = [
  { key: 'name', label: 'Account', align: 'start' },
  { key: 'plan', label: 'Plan', align: 'start' },
  { key: 'mrr', label: 'MRR', align: 'end', hideBelowSm: true },
  { key: 'status', label: 'Status', align: 'start' },
];

const compareBy = (a: Account, b: Account, key: SortKey) => {
  if (key === 'mrr') return a.mrr - b.mrr;
  if (key === 'plan') return PLAN_RANK[a.plan] - PLAN_RANK[b.plan];
  if (key === 'status') return STATUS_RANK[a.status] - STATUS_RANK[b.status];

  return a.name.localeCompare(b.name);
};

/** Negative deltas use a true minus sign (U+2212), not a hyphen. */
const formatDelta = ({ delta, unit }: Pick<Metric, 'delta' | 'unit'>) => {
  const sign = delta > 0 ? '+' : delta < 0 ? '−' : '';

  return `${sign}${Math.abs(delta)}${unit}`;
};

const deltaTone = ({ delta, goodWhen }: Pick<Metric, 'delta' | 'goodWhen'>) => {
  if (delta === 0) return 'text-muted-foreground';
  const improving = delta > 0 === (goodWhen === 'up');

  return improving ? 'text-success' : 'text-destructive';
};

const deltaLabel = ({ label, delta, unit }: Metric) => {
  const direction = delta > 0 ? 'up' : delta < 0 ? 'down' : 'unchanged';
  const measure = unit === '%' ? 'percent' : 'points';

  return `${label} ${direction} ${Math.abs(delta)} ${measure} against the previous 30 days`;
};

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 isEditableElsewhere = (target: EventTarget | null, search: HTMLElement | null) => {
  if (!(target instanceof HTMLElement) || target === search) return false;

  return target.isContentEditable || target.matches('input, textarea, select');
};

const AppShell01 = () => {
  const [query, setQuery] = React.useState('');
  const [sortKey, setSortKey] = React.useState<SortKey>('mrr');
  const [sortDirection, setSortDirection] = React.useState<SortDirection>('desc');
  const searchRef = React.useRef<HTMLInputElement>(null);

  React.useEffect(() => {
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.defaultPrevented || event.shiftKey || event.altKey) return;
      const isSearchShortcut = event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey);
      if (!isSearchShortcut || isEditableElsewhere(event.target, searchRef.current)) return;
      event.preventDefault();
      searchRef.current?.focus();
      searchRef.current?.select();
    };
    document.addEventListener('keydown', onKeyDown);

    return () => document.removeEventListener('keydown', onKeyDown);
  }, []);

  const visibleRows = React.useMemo(() => {
    const q = query.trim().toLowerCase();
    const matches = q
      ? ROWS.filter(
          (r) =>
            r.name.toLowerCase().includes(q) || r.plan.toLowerCase().includes(q) || r.status.toLowerCase().includes(q),
        )
      : ROWS;
    const sorted = [...matches].sort((a, b) => compareBy(a, b, sortKey));

    return sortDirection === 'asc' ? sorted : sorted.reverse();
  }, [query, sortKey, sortDirection]);

  const toggleSort = (key: SortKey) => {
    if (key === sortKey) {
      setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'));

      return;
    }
    setSortKey(key);
    setSortDirection(key === 'name' ? 'asc' : 'desc');
  };

  const sortedColumnLabel = COLUMNS.find((c) => c.key === sortKey)?.label;

  // 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} collapsible="icon">
        <SidebarHeader>
          <SidebarMenu>
            <SidebarMenuItem>
              <SidebarMenuButton size="lg" tooltip="Hirael">
                <span className="flex aspect-square size-8 shrink-0 items-center justify-center rounded-sm text-sidebar-foreground">
                  <BrandMark className="size-5" />
                </span>
                <div className="grid flex-1 text-start leading-tight">
                  <span className="truncate text-sm font-semibold tracking-[-0.01em]">Hirael</span>
                  <span className="flex gap-1.5 truncate text-xs text-muted-foreground uppercase">
                    <span>hirael</span>
                    <span className="text-border">|</span>
                    <span>pro</span>
                  </span>
                </div>
              </SidebarMenuButton>
            </SidebarMenuItem>
          </SidebarMenu>
        </SidebarHeader>

        <SidebarContent className="overflow-hidden">
          <SidebarGroup>
            <SidebarGroupLabel>Workspace</SidebarGroupLabel>
            <SidebarGroupContent>
              <SidebarMenu>
                {PRIMARY.map((item) => (
                  <SidebarMenuItem key={item.label}>
                    <SidebarMenuButton asChild isActive={item.active} tooltip={item.label}>
                      <a href={item.href} aria-current={item.active ? 'page' : undefined}>
                        <item.icon />
                        <span>{item.label}</span>
                      </a>
                    </SidebarMenuButton>
                    {item.badge && (
                      <SidebarMenuBadge>
                        {item.badge}
                        <span className="sr-only"> unread</span>
                      </SidebarMenuBadge>
                    )}
                  </SidebarMenuItem>
                ))}
              </SidebarMenu>
            </SidebarGroupContent>
          </SidebarGroup>

          <SidebarSeparator />

          <SidebarGroup>
            <SidebarGroupLabel>Account</SidebarGroupLabel>
            <SidebarGroupContent>
              <SidebarMenu>
                {SECONDARY.map((item) => (
                  <SidebarMenuItem key={item.label}>
                    <SidebarMenuButton asChild tooltip={item.label}>
                      <a href={item.href}>
                        <item.icon />
                        <span>{item.label}</span>
                      </a>
                    </SidebarMenuButton>
                  </SidebarMenuItem>
                ))}
              </SidebarMenu>
            </SidebarGroupContent>
          </SidebarGroup>
        </SidebarContent>

        <SidebarFooter>
          <SidebarMenu>
            <SidebarMenuItem>
              <DropdownMenu>
                <DropdownMenuTrigger asChild>
                  <SidebarMenuButton size="lg" tooltip="Mohammad Shehadeh">
                    <span className="flex aspect-square size-8 shrink-0 items-center justify-center rounded-md bg-muted text-[10px] font-medium text-foreground">
                      MS
                    </span>
                    <div className="grid min-w-0 flex-1 text-start leading-tight">
                      <span className="truncate text-xs font-medium">Mohammad Shehadeh</span>
                      <span className="flex gap-1.5 truncate text-xs text-muted-foreground uppercase">
                        <span>admin</span>
                        <span className="text-border">|</span>
                        <span>hirael</span>
                      </span>
                    </div>
                    <ChevronsUpDown className="ms-auto size-3.5 shrink-0 text-muted-foreground" />
                  </SidebarMenuButton>
                </DropdownMenuTrigger>
                <DropdownMenuContent side="top" align="start" sideOffset={8} className="w-56">
                  <DropdownMenuLabel>
                    <span className="block text-sm font-medium">Mohammad Shehadeh</span>
                    <span className="block truncate text-xs text-muted-foreground">hello@mohammadshehadeh.com</span>
                  </DropdownMenuLabel>
                  <DropdownMenuSeparator />
                  <DropdownMenuItem>
                    <Settings />
                    Account settings
                  </DropdownMenuItem>
                  <DropdownMenuItem>
                    <CreditCard />
                    Billing
                  </DropdownMenuItem>
                  <DropdownMenuSeparator />
                  <DropdownMenuItem variant="destructive">
                    <LogOut />
                    Sign out
                  </DropdownMenuItem>
                </DropdownMenuContent>
              </DropdownMenu>
            </SidebarMenuItem>
          </SidebarMenu>
        </SidebarFooter>

        <SidebarRail />
      </Sidebar>

      <SidebarInset className="min-w-0">
        <header className="sticky top-0 z-10 flex h-14 items-center gap-2 border-b border-border bg-background/80 px-3 backdrop-blur sm:px-4">
          <SidebarTrigger className="-ms-1" />
          <Separator orientation="vertical" className="mx-1 hidden data-[orientation=vertical]:h-5 sm:block" />
          <nav
            aria-label="Breadcrumb"
            className="hidden items-center gap-1.5 text-xs text-muted-foreground uppercase sm:flex"
          >
            <span>Workspace</span>
            <ChevronRight className="size-3 rtl:rotate-180" aria-hidden />
            <span className="text-foreground">Dashboard</span>
          </nav>

          <InputGroup className="ms-auto h-8 max-w-xs">
            <InputGroupAddon align="inline-start">
              <Search className="size-3.5" aria-hidden />
            </InputGroupAddon>
            <InputGroupInput
              ref={searchRef}
              type="search"
              placeholder="Search accounts…"
              value={query}
              onChange={(e) => setQuery(e.target.value)}
              onKeyDown={(e) => {
                if (e.key !== 'Escape') return;
                if (query) {
                  e.preventDefault();
                  setQuery('');

                  return;
                }
                e.currentTarget.blur();
              }}
              aria-label="Search accounts"
              aria-keyshortcuts="Meta+K Control+K"
            />
            <InputGroupAddon dir="ltr" align="inline-end" className="hidden sm:flex">
              <Kbd>
                <Command className="size-3" aria-hidden />
              </Kbd>
              <Kbd>K</Kbd>
            </InputGroupAddon>
          </InputGroup>

          <Button variant="outline" size="icon" aria-label="Notifications, 3 unread" className="relative size-8">
            <Bell className="size-3.5" aria-hidden />
            <span aria-hidden className="absolute end-1.5 top-1.5 size-1.5 rounded-full bg-foreground" />
          </Button>
          <Button size="sm" className="hidden sm:inline-flex">
            <Plus className="size-3" aria-hidden />
            New customer
          </Button>
          <Button size="icon" className="size-8 sm:hidden" aria-label="New customer">
            <Plus className="size-3.5" aria-hidden />
          </Button>
        </header>

        <div data-slot="app-shell-main" className={cn(ENTER, 'flex flex-1 flex-col gap-5 p-4 sm:p-6')}>
          <div className="flex flex-col gap-1">
            <h1 className="text-2xl font-semibold tracking-[-0.02em]">Dashboard</h1>
            <p className="text-sm text-muted-foreground">
              An overview of customers, revenue, and recent activity. Changes compare against the previous 30 days.
            </p>
          </div>

          <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
            {METRICS.map((m) => (
              <Card key={m.label}>
                <CardContent>
                  <div className="flex flex-col gap-1">
                    <span className="text-xs text-muted-foreground uppercase">{m.label}</span>
                    <span className="text-lg font-semibold tabular-nums">{m.value}</span>
                    <span dir="ltr" aria-label={deltaLabel(m)} className={cn('text-[10px] tabular-nums', deltaTone(m))}>
                      {formatDelta(m)}
                    </span>
                  </div>
                </CardContent>
              </Card>
            ))}
          </div>

          <div
            data-slot="app-shell-table"
            className={cn(
              ENTER,
              'overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-sm delay-80',
            )}
          >
            <div className="flex items-center justify-between border-b border-border px-4 py-2.5">
              <span aria-live="polite" className="text-xs text-muted-foreground uppercase">
                recent accounts
                {query.trim() && (
                  <span className="ms-2 text-foreground">
                    ({visibleRows.length} of {ROWS.length})
                  </span>
                )}
              </span>
              <Button variant="link" size="sm" className="h-auto" asChild>
                <a href="#">View all</a>
              </Button>
            </div>

            {visibleRows.length === 0 ? (
              <Empty className={SWAP}>
                <EmptyHeader>
                  <EmptyMedia variant="icon">
                    <Search />
                  </EmptyMedia>
                  <EmptyTitle>No matching accounts</EmptyTitle>
                  <EmptyDescription>
                    Nothing matched <span className="text-foreground">&ldquo;{query.trim()}&rdquo;</span>. Try a company
                    name, a plan, or a status.
                  </EmptyDescription>
                </EmptyHeader>
                <EmptyContent>
                  <Button
                    variant="outline"
                    size="sm"
                    onClick={() => {
                      setQuery('');
                      searchRef.current?.focus();
                    }}
                  >
                    Clear search
                  </Button>
                </EmptyContent>
              </Empty>
            ) : (
              <Table className="w-full">
                <caption className="sr-only">
                  Recent accounts, sorted by {sortedColumnLabel} {sortDirection === 'asc' ? 'ascending' : 'descending'}.
                </caption>
                <TableHeader>
                  <TableRow>
                    {COLUMNS.map((column) => {
                      const isSorted = column.key === sortKey;

                      return (
                        <TableHead
                          key={column.key}
                          aria-sort={isSorted ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'}
                          className={cn('h-auto', column.hideBelowSm && 'hidden sm:table-cell')}
                        >
                          <button
                            type="button"
                            onClick={() => toggleSort(column.key)}
                            className={cn(
                              'flex w-full items-center gap-1 px-2 py-2 text-xs uppercase transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-inset',
                              column.align === 'end' && 'justify-end',
                              isSorted ? 'text-foreground' : 'text-muted-foreground',
                            )}
                          >
                            {column.label}
                            {isSorted ? (
                              sortDirection === 'asc' ? (
                                <ArrowUp className="size-3" aria-hidden />
                              ) : (
                                <ArrowDown className="size-3" aria-hidden />
                              )
                            ) : (
                              <ChevronsUpDown className="size-3 opacity-40" aria-hidden />
                            )}
                          </button>
                        </TableHead>
                      );
                    })}
                    <TableHead className="h-auto text-end">
                      <span className="sr-only">Actions</span>
                    </TableHead>
                  </TableRow>
                </TableHeader>
                <TableBody>
                  {visibleRows.map((r) => {
                    const tone = STATUS_TONE[r.status];

                    return (
                      <TableRow key={r.name}>
                        <TableCell>
                          <span className="inline-flex items-center gap-2">
                            <span
                              aria-hidden
                              className="inline-flex size-6 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-foreground"
                            >
                              {r.initials}
                            </span>
                            <span className="font-medium">{r.name}</span>
                          </span>
                        </TableCell>
                        <TableCell>
                          <Badge variant={r.plan === 'Hobby' ? 'outline' : 'secondary'}>{r.plan}</Badge>
                        </TableCell>
                        <TableCell className="hidden text-end sm:table-cell">
                          <span className="tabular-nums">{usd.format(r.mrr)}</span>
                        </TableCell>
                        <TableCell>
                          <span className="inline-flex items-center gap-1.5 text-xs uppercase">
                            <span aria-hidden className={cn('size-1.5 rounded-full', tone.dot)} />
                            <span className={tone.text}>{r.status}</span>
                          </span>
                        </TableCell>
                        <TableCell className="text-end">
                          <DropdownMenu>
                            <DropdownMenuTrigger asChild>
                              <Button
                                variant="ghost"
                                size="icon"
                                className="size-7"
                                aria-label={`Actions for ${r.name}`}
                              >
                                <MoreHorizontal className="size-3.5" aria-hidden />
                              </Button>
                            </DropdownMenuTrigger>
                            <DropdownMenuContent align="end" className="w-44">
                              <DropdownMenuItem>View account</DropdownMenuItem>
                              <DropdownMenuItem>Manage billing</DropdownMenuItem>
                              <DropdownMenuItem>Invite teammate</DropdownMenuItem>
                              <DropdownMenuSeparator />
                              <DropdownMenuItem variant="destructive">Remove account</DropdownMenuItem>
                            </DropdownMenuContent>
                          </DropdownMenu>
                        </TableCell>
                      </TableRow>
                    );
                  })}
                </TableBody>
              </Table>
            )}
          </div>
        </div>
      </SidebarInset>
    </SidebarProvider>
  );
};

export default AppShell01;
```
