App Shell 1

Drop-in admin shell layout built on the shadcn Sidebar primitive: collapsible icon-rail sidebar with nav badges and a footer profile row, sticky topbar with breadcrumb, command-palette search and notification button, plus a live-filtering accounts table in the main area.

Preview

Installation

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

Code

components/blocks/app-shell-01.tsx
"use client";

import * as React from "react";
import {
  Bell,
  ChevronRight,
  Command,
  Compass,
  CreditCard,
  Inbox,
  LayoutDashboard,
  LifeBuoy,
  MoreHorizontal,
  Plus,
  Search,
  Settings,
  Users,
  type LucideIcon,
} from "lucide-react";

import { Badge } from "@/registry/hirael/ui/badge";
import { Button } from "@/registry/hirael/ui/button";
import { Card } from "@/registry/hirael/ui/card";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/registry/hirael/ui/input-group";
import { KbdDisplay } from "@/registry/hirael/components/kbd";
import { Separator } from "@/registry/hirael/ui/separator";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/registry/hirael/ui/table";
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupContent,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInset,
  SidebarMenu,
  SidebarMenuBadge,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
  SidebarSeparator,
  SidebarTrigger,
} from "@/registry/hirael/ui/sidebar";

type 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 Account = {
  name: string;
  plan: "Hobby" | "Pro" | "Team";
  mrr: string;
  status: "Active" | "Trial" | "Past due";
  initials: string;
};

const ROWS: readonly Account[] = [
  {
    name: "Plinth Labs",
    plan: "Pro",
    mrr: "$2,480",
    status: "Active",
    initials: "PL",
  },
  {
    name: "Helix",
    plan: "Team",
    mrr: "$6,120",
    status: "Active",
    initials: "HX",
  },
  { name: "Brella", plan: "Hobby", mrr: "$0", status: "Trial", initials: "BR" },
  {
    name: "Verbit",
    plan: "Pro",
    mrr: "$1,860",
    status: "Past due",
    initials: "VB",
  },
  {
    name: "Mercado",
    plan: "Team",
    mrr: "$5,400",
    status: "Active",
    initials: "MC",
  },
];

const METRICS = [
  { l: "Customers", v: "1,284", d: "+4.1%" },
  { l: "MRR", v: "$48.2k", d: "+8.7%" },
  { l: "Churn", v: "1.8%", d: "-0.4%" },
  { l: "NPS", v: "62", d: "+2" },
] as const;

function statusDot(status: Account["status"]) {
  if (status === "Active") return "bg-success";
  if (status === "Trial") return "bg-warning";
  return "bg-destructive";
}

function statusText(status: Account["status"]) {
  if (status === "Active") return "text-success";
  if (status === "Trial") return "text-warning";
  return "text-destructive";
}

function 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>
  );
}

export default function AppShell01() {
  const [query, setQuery] = React.useState("");
  const filteredRows = React.useMemo(() => {
    const q = query.trim().toLowerCase();
    if (!q) return ROWS;
    return ROWS.filter(
      (r) =>
        r.name.toLowerCase().includes(q) ||
        r.plan.toLowerCase().includes(q) ||
        r.status.toLowerCase().includes(q),
    );
  }, [query]);

  return (
    <SidebarProvider>
      <Sidebar collapsible="icon">
        <SidebarHeader>
          <SidebarMenu>
            <SidebarMenuItem>
              <SidebarMenuButton size="lg" tooltip="Hirael">
                <span className="flex aspect-square size-8 shrink-0 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-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="truncate font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
                    plinth labs · pro
                  </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}>
                        <item.icon />
                        <span>{item.label}</span>
                      </a>
                    </SidebarMenuButton>
                    {item.badge && (
                      <SidebarMenuBadge>{item.badge}</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>
              <SidebarMenuButton size="lg" tooltip="Mohammad Shehadeh">
                <span className="flex aspect-square size-8 shrink-0 items-center justify-center rounded-md bg-sidebar-primary font-mono text-[10px] font-medium text-sidebar-primary-foreground">
                  MS
                </span>
                <div className="grid flex-1 text-start leading-tight">
                  <span className="truncate text-xs font-medium">
                    Mohammad Shehadeh
                  </span>
                  <span className="truncate font-mono text-[9px] uppercase tracking-[0.1em] text-muted-foreground">
                    admin · plinth labs
                  </span>
                </div>
              </SidebarMenuButton>
            </SidebarMenuItem>
          </SidebarMenu>
        </SidebarFooter>
      </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 h-5 sm:block"
          />
          <nav
            aria-label="Breadcrumb"
            className="hidden items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground 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" />
            </InputGroupAddon>
            <InputGroupInput
              placeholder="Search accounts…"
              value={query}
              onChange={(e) => setQuery(e.target.value)}
              aria-label="Search accounts"
            />
            <InputGroupAddon align="inline-end" className="hidden sm:flex">
              <KbdDisplay>
                <Command className="size-3" />
              </KbdDisplay>
              <KbdDisplay>K</KbdDisplay>
            </InputGroupAddon>
          </InputGroup>

          <Button
            variant="outline"
            size="icon"
            aria-label="Notifications"
            className="relative size-8"
          >
            <Bell className="size-3.5" />
            <span 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" />
            New customer
          </Button>
          <Button
            size="icon"
            className="size-8 sm:hidden"
            aria-label="New customer"
          >
            <Plus className="size-3.5" />
          </Button>
        </header>

        <div className="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.
            </p>
          </div>

          <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
            {METRICS.map((m) => (
              <Card key={m.l} className="gap-1 p-3">
                <span className="font-mono text-[9px] uppercase tracking-[0.12em] text-muted-foreground">
                  {m.l}
                </span>
                <span className="text-lg font-semibold tabular-nums">
                  {m.v}
                </span>
                <span className="font-mono text-[10px] tabular-nums text-success">
                  {m.d}
                </span>
              </Card>
            ))}
          </div>

          <Card className="gap-0 overflow-hidden p-0">
            <div className="flex items-center justify-between border-b border-border px-4 py-2.5">
              <span className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
                recent accounts
                {query && (
                  <span className="ms-2 text-foreground">
                    ({filteredRows.length} of {ROWS.length})
                  </span>
                )}
              </span>
              <Button variant="link" size="sm" className="h-auto p-0" asChild>
                <a href="#">View all</a>
              </Button>
            </div>

            {filteredRows.length === 0 ? (
              <div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
                <p className="text-sm font-medium">No matches</p>
                <p className="text-xs text-muted-foreground">
                  Nothing matched{" "}
                  <span className="font-mono text-foreground">
                    &ldquo;{query}&rdquo;
                  </span>
                  . Clear the search to see all accounts.
                </p>
                <Button
                  variant="outline"
                  size="sm"
                  className="mt-2"
                  onClick={() => setQuery("")}
                >
                  Clear search
                </Button>
              </div>
            ) : (
              <Table className="w-full">
                <TableHeader>
                  <TableRow className="font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground hover:bg-transparent">
                    <TableHead className="h-auto px-4 py-2 text-start font-normal text-muted-foreground">
                      Account
                    </TableHead>
                    <TableHead className="h-auto px-4 py-2 text-start font-normal text-muted-foreground">
                      Plan
                    </TableHead>
                    <TableHead className="hidden h-auto px-4 py-2 text-start font-normal text-muted-foreground sm:table-cell">
                      MRR
                    </TableHead>
                    <TableHead className="h-auto px-4 py-2 text-start font-normal text-muted-foreground">
                      Status
                    </TableHead>
                    <TableHead className="h-auto px-4 py-2 text-end font-normal text-muted-foreground">
                      <span className="sr-only">Actions</span>
                    </TableHead>
                  </TableRow>
                </TableHeader>
                <TableBody>
                  {filteredRows.map((r) => (
                    <TableRow
                      key={r.name}
                      className="text-sm hover:bg-accent/30"
                    >
                      <TableCell className="px-4 py-2.5">
                        <span className="inline-flex items-center gap-2">
                          <span className="inline-flex size-6 items-center justify-center rounded-full bg-muted font-mono text-[10px] font-medium text-foreground">
                            {r.initials}
                          </span>
                          <span className="font-medium">{r.name}</span>
                        </span>
                      </TableCell>
                      <TableCell className="px-4 py-2.5">
                        <Badge
                          variant={r.plan === "Hobby" ? "outline" : "default"}
                          className="font-mono"
                        >
                          {r.plan}
                        </Badge>
                      </TableCell>
                      <TableCell className="hidden px-4 py-2.5 font-mono tabular-nums text-foreground sm:table-cell">
                        {r.mrr}
                      </TableCell>
                      <TableCell className="px-4 py-2.5">
                        <span className="inline-flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.1em]">
                          <span
                            className={`size-1.5 rounded-full ${statusDot(r.status)}`}
                          />
                          <span className={statusText(r.status)}>
                            {r.status}
                          </span>
                        </span>
                      </TableCell>
                      <TableCell className="px-4 py-2.5 text-end">
                        <Button
                          variant="ghost"
                          size="icon"
                          className="size-7"
                          aria-label={`Actions for ${r.name}`}
                        >
                          <MoreHorizontal className="size-3.5" />
                        </Button>
                      </TableCell>
                    </TableRow>
                  ))}
                </TableBody>
              </Table>
            )}
          </Card>
        </div>
      </SidebarInset>
    </SidebarProvider>
  );
}

Dependencies

shadcn registry

badgebuttoncardinput-groupkbdseparatorsidebartable

npm

lucide-react