App Shell 2
Sidebar-free admin shell with a sticky top navigation bar (logo, primary links, search and avatar) over a settings layout: an in-page vertical nav switches a detail card of definition-list fields with per-field edit actions.
Preview
Installation
npx shadcn@latest add https://hirael.com/r/app-shell-02.jsonCode
components/blocks/app-shell-02.tsx
"use client";
import * as React from "react";
import {
Bell,
CreditCard,
KeyRound,
Plug,
Search,
Shield,
User,
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 { Separator } from "@/registry/hirael/ui/separator";
import { cn } from "@/lib/utils";
const NAV = ["Overview", "Projects", "Activity", "Settings"] as const;
type SettingsTab = {
id: string;
label: string;
icon: LucideIcon;
desc: string;
};
const TABS: readonly SettingsTab[] = [
{
id: "profile",
label: "Profile",
icon: User,
desc: "Your personal details",
},
{ id: "security", label: "Security", icon: Shield, desc: "Password and 2FA" },
{ id: "api", label: "API keys", icon: KeyRound, desc: "Tokens and access" },
{
id: "billing",
label: "Billing",
icon: CreditCard,
desc: "Plan and invoices",
},
{
id: "integrations",
label: "Integrations",
icon: Plug,
desc: "Connected apps",
},
];
type FieldDef = { label: string; value: string; hint?: string };
const PANELS: Record<string, FieldDef[]> = {
profile: [
{ label: "Full name", value: "Mohammad Shehadeh" },
{ label: "Email", value: "mohammad@plinth.dev", hint: "Used for sign-in" },
{ label: "Role", value: "Workspace admin" },
],
security: [
{ label: "Password", value: "••••••••••", hint: "Updated 12 days ago" },
{ label: "Two-factor auth", value: "Authenticator app" },
{ label: "Active sessions", value: "3 devices" },
],
api: [
{
label: "Production key",
value: "msh_live_••••8f2a",
hint: "Last used 2h ago",
},
{ label: "Development key", value: "msh_test_••••1c0d" },
{ label: "Webhook secret", value: "whsec_••••44b9" },
],
billing: [
{ label: "Current plan", value: "Pro · $24/mo", hint: "Renews 2026-06-14" },
{ label: "Payment method", value: "Visa ending 4242" },
{ label: "Billing email", value: "ap@plinth.dev" },
],
integrations: [
{ label: "Vercel", value: "Connected", hint: "plinth-labs" },
{ label: "Slack", value: "Connected", hint: "#product" },
{ label: "Linear", value: "Not connected" },
],
};
function BrandMark({ className }: { className?: string }) {
return (
<span
role="img"
aria-label="Hirael"
className={cn(
"flex aspect-square items-center justify-center rounded-md bg-foreground text-background",
className,
)}
>
<svg
viewBox="0 0 80 100"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className="size-[64%]"
>
<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>
</span>
);
}
export default function AppShell02() {
const [active, setActive] = React.useState<string>("profile");
const tab = TABS.find((t) => t.id === active) ?? TABS[0];
const fields = PANELS[active] ?? [];
return (
<div className="flex min-h-[640px] flex-col bg-background">
<header className="sticky top-0 z-20 border-b border-border bg-background/80 backdrop-blur">
<div className="container flex h-14 w-full items-center gap-3">
<BrandMark className="size-7 shrink-0" />
<Separator
orientation="vertical"
className="mx-1 hidden h-5 sm:block"
/>
<nav
aria-label="Primary"
className="hidden items-center gap-1 md:flex"
>
{NAV.map((item) => (
<a
key={item}
href="#"
aria-current={item === "Settings" ? "page" : undefined}
className="rounded-md px-2.5 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground aria-[current=page]:bg-accent aria-[current=page]:text-foreground"
>
{item}
</a>
))}
</nav>
<InputGroup className="ms-auto h-8 max-w-[200px]">
<InputGroupAddon align="inline-start">
<Search className="size-3.5" />
</InputGroupAddon>
<InputGroupInput placeholder="Search…" aria-label="Search" />
</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>
<span className="inline-flex size-8 items-center justify-center rounded-full bg-foreground font-mono text-[10px] font-medium text-background">
MS
</span>
</div>
</header>
<div className="container w-full py-6 sm:py-8">
<div className="flex flex-col gap-1">
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
workspace · plinth labs
</span>
<h1 className="text-2xl font-semibold tracking-[-0.02em]">
Settings
</h1>
<p className="text-sm text-muted-foreground">
Manage your account, security, and workspace integrations.
</p>
</div>
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-12">
<nav aria-label="Settings" className="lg:col-span-3">
<ul className="flex gap-1 overflow-x-auto lg:flex-col lg:overflow-visible">
{TABS.map((t) => {
const isActive = t.id === active;
return (
<li key={t.id} className="shrink-0 lg:shrink">
<button
type="button"
onClick={() => setActive(t.id)}
aria-current={isActive ? "page" : undefined}
className="flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-start text-sm transition-colors hover:bg-accent aria-[current=page]:bg-accent aria-[current=page]:font-medium"
>
<t.icon className="size-4 shrink-0 text-muted-foreground" />
<span className="whitespace-nowrap">{t.label}</span>
</button>
</li>
);
})}
</ul>
</nav>
<div className="lg:col-span-9">
<Card className="gap-0 overflow-hidden p-0">
<div className="flex items-center justify-between gap-3 border-b border-border px-5 py-4">
<div className="flex items-center gap-3">
<span className="flex size-9 items-center justify-center rounded-md border border-border bg-muted">
<tab.icon className="size-4" />
</span>
<div className="flex flex-col">
<span className="text-sm font-semibold">{tab.label}</span>
<span className="text-xs text-muted-foreground">
{tab.desc}
</span>
</div>
</div>
<Badge
variant="outline"
className="hidden font-mono sm:inline-flex"
>
{fields.length} fields
</Badge>
</div>
<dl className="divide-y divide-border">
{fields.map((f) => (
<div
key={f.label}
className="flex flex-col gap-1 px-5 py-4 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
>
<div className="flex flex-col">
<dt className="text-sm font-medium">{f.label}</dt>
{f.hint && (
<dd className="font-mono text-[11px] text-muted-foreground">
{f.hint}
</dd>
)}
</div>
<dd className="flex items-center gap-3">
<span className="font-mono text-sm tabular-nums text-foreground">
{f.value}
</span>
<Button variant="outline" size="sm" className="h-7">
Edit
</Button>
</dd>
</div>
))}
</dl>
<div className="flex items-center justify-between border-t border-border bg-muted/30 px-5 py-3">
<span className="font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">
changes save automatically
</span>
<Button size="sm">Done</Button>
</div>
</Card>
</div>
</div>
</div>
</div>
);
}
Dependencies
shadcn registry
badgebuttoncardinput-groupseparator
npm
lucide-react