App Shell 3
Split-pane inbox shell: icon rail with unread indicator, searchable conversation list with an unread filter, and a reading pane with star toggle and a reply composer that appends to the thread.
Preview
Installation
npx shadcn@latest add https://hirael.com/r/app-shell-03.jsonCode
components/blocks/app-shell-03.tsx
"use client";
import * as React from "react";
import {
Archive,
Inbox,
Search,
Send,
SendHorizonal,
Settings,
Star,
Trash2,
type LucideIcon,
} from "lucide-react";
import { Badge } from "@/registry/hirael/ui/badge";
import { Button } from "@/registry/hirael/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/registry/hirael/ui/input-group";
import { Separator } from "@/registry/hirael/ui/separator";
import { Tabs, TabsList, TabsTrigger } from "@/registry/hirael/ui/tabs";
import { Textarea } from "@/registry/hirael/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/registry/hirael/ui/tooltip";
import { cn } from "@/lib/utils";
type Message = { from: string; initials: string; time: string; body: string };
type Conversation = {
id: string;
sender: string;
initials: string;
email: string;
subject: string;
preview: string;
time: string;
unread?: boolean;
thread: readonly Message[];
};
const CONVERSATIONS: readonly Conversation[] = [
{
id: "design-review",
sender: "Maya Renner",
initials: "MR",
email: "maya@plinth.dev",
subject: "Design review · pricing page",
preview: "Left comments on the tier cards, the middle one still",
time: "9:41",
unread: true,
thread: [
{
from: "Maya Renner",
initials: "MR",
time: "Today · 9:41",
body: "Left comments on the tier cards. The middle one still reads as selected even when it isn't. Can we tone the border down a step?",
},
{
from: "Maya Renner",
initials: "MR",
time: "Today · 9:44",
body: "Also flagged the annual toggle. It works, it just doesn't look like it does anything until you spot the price change.",
},
],
},
{
id: "invoice-april",
sender: "Billing · Northbeam",
initials: "NB",
email: "billing@northbeam.io",
subject: "Invoice #2204 is ready",
preview: "Your April invoice for $1,188.00 is attached and due",
time: "8:17",
unread: true,
thread: [
{
from: "Billing · Northbeam",
initials: "NB",
time: "Today · 8:17",
body: "Your April invoice for $1,188.00 is attached and due on May 14. No action needed if auto-pay is enabled.",
},
],
},
{
id: "launch-checklist",
sender: "Jules Tanaka",
initials: "JT",
email: "jules@quantfold.com",
subject: "Launch checklist: two items left",
preview: "Status page and the rollback runbook. Everything else",
time: "Yesterday",
thread: [
{
from: "Jules Tanaka",
initials: "JT",
time: "Yesterday · 17:02",
body: "Status page and the rollback runbook. Everything else on the checklist is green; staging soak finished clean overnight.",
},
{
from: "You",
initials: "YO",
time: "Yesterday · 17:20",
body: "Runbook draft is in the shared folder. I'll take the status page tomorrow morning.",
},
],
},
{
id: "support-export",
sender: "Adaeze Okafor",
initials: "AO",
email: "adaeze@stackline.co",
subject: "Re: CSV export drops timezone",
preview: "Confirmed on our side; exports created after the fix",
time: "Yesterday",
thread: [
{
from: "Adaeze Okafor",
initials: "AO",
time: "Yesterday · 14:33",
body: "Confirmed on our side; exports created after the fix carry the offset correctly. Thanks for turning that around quickly.",
},
],
},
{
id: "onboarding-feedback",
sender: "Soren Kim",
initials: "SK",
email: "soren@driftwork.com",
subject: "Onboarding feedback from the pilot team",
preview: "Three of five finished setup without docs. The two who",
time: "Mon",
unread: true,
thread: [
{
from: "Soren Kim",
initials: "SK",
time: "Monday · 11:08",
body: "Three of five finished setup without docs. The two who stalled both hit the same step: connecting the first data source.",
},
],
},
{
id: "offsite-dates",
sender: "Lena Voss",
initials: "LV",
email: "lena@helioslab.dev",
subject: "Offsite dates: last call",
preview: "Locking the venue Friday. If the second week of June",
time: "Mon",
thread: [
{
from: "Lena Voss",
initials: "LV",
time: "Monday · 9:30",
body: "Locking the venue Friday. If the second week of June doesn't work for anyone, speak now.",
},
],
},
{
id: "security-rotation",
sender: "Security bot",
initials: "SB",
email: "noreply@plinth.dev",
subject: "API key rotation completed",
preview: "Production keys rotated on schedule. 2 services picked",
time: "Sun",
thread: [
{
from: "Security bot",
initials: "SB",
time: "Sunday · 03:00",
body: "Production keys rotated on schedule. 2 services picked up the new credentials automatically; none required manual restarts.",
},
],
},
];
const RAIL: { icon: LucideIcon; label: string; current?: boolean }[] = [
{ icon: Inbox, label: "Inbox", current: true },
{ icon: Send, label: "Sent" },
{ icon: Archive, label: "Archive" },
{ icon: Trash2, label: "Trash" },
];
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 AppShell03() {
const [selectedId, setSelectedId] = React.useState(CONVERSATIONS[0].id);
const [readIds, setReadIds] = React.useState<readonly string[]>([]);
const [starred, setStarred] = React.useState<readonly string[]>([
"launch-checklist",
]);
const [replies, setReplies] = React.useState<Record<string, Message[]>>({});
const [query, setQuery] = React.useState("");
const [filter, setFilter] = React.useState<"all" | "unread">("all");
const [draft, setDraft] = React.useState("");
const isUnread = (c: Conversation) => !!c.unread && !readIds.includes(c.id);
const unreadCount = CONVERSATIONS.filter(isUnread).length;
const normalized = query.trim().toLowerCase();
const visible = CONVERSATIONS.filter((c) => {
if (filter === "unread" && !isUnread(c)) return false;
if (!normalized) return true;
return (
c.sender.toLowerCase().includes(normalized) ||
c.subject.toLowerCase().includes(normalized)
);
});
const selected =
CONVERSATIONS.find((c) => c.id === selectedId) ?? CONVERSATIONS[0];
const thread = [...selected.thread, ...(replies[selected.id] ?? [])];
const isStarred = starred.includes(selected.id);
const openConversation = (id: string) => {
setSelectedId(id);
setDraft("");
setReadIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
};
const toggleStar = () =>
setStarred((prev) =>
prev.includes(selected.id)
? prev.filter((s) => s !== selected.id)
: [...prev, selected.id],
);
const sendReply = () => {
const body = draft.trim();
if (!body) return;
setReplies((prev) => ({
...prev,
[selected.id]: [
...(prev[selected.id] ?? []),
{ from: "You", initials: "YO", time: "Just now", body },
],
}));
setDraft("");
};
return (
<div className="flex min-h-[640px] bg-background">
<aside
aria-label="Mailboxes"
className="flex w-14 shrink-0 flex-col items-center gap-1 border-e border-border py-3"
>
<span
role="img"
aria-label="Hirael"
className="mb-2 flex size-8 items-center justify-center rounded-md bg-foreground text-background"
>
<BrandMark className="size-5" />
</span>
{RAIL.map((item) => (
<Tooltip key={item.label}>
<TooltipTrigger asChild>
<button
type="button"
aria-label={item.label}
aria-current={item.current ? "page" : undefined}
className={cn(
"relative inline-flex size-9 items-center justify-center rounded-md transition-colors",
item.current
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
<item.icon className="size-4" />
{item.current && unreadCount > 0 && (
<span className="absolute end-1.5 top-1.5 size-1.5 rounded-full bg-foreground" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="right">{item.label}</TooltipContent>
</Tooltip>
))}
<div className="mt-auto flex flex-col items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Settings"
className="inline-flex size-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<Settings className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="right">Settings</TooltipContent>
</Tooltip>
<span className="inline-flex size-8 items-center justify-center rounded-full border border-border bg-card font-mono text-[11px] font-medium">
MS
</span>
</div>
</aside>
<section
aria-label="Conversations"
className="hidden w-80 shrink-0 flex-col border-e border-border md:flex"
>
<div className="flex h-14 shrink-0 items-center justify-between gap-2 px-4">
<h2 className="text-sm font-medium tracking-[-0.01em]">Inbox</h2>
<Badge
variant="outline"
className="font-mono text-[10px] tabular-nums"
>
{unreadCount} unread
</Badge>
</div>
<div className="flex flex-col gap-2.5 px-4 pb-3">
<InputGroup className="h-8">
<InputGroupAddon align="inline-start">
<Search className="size-3.5" />
</InputGroupAddon>
<InputGroupInput
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search mail…"
aria-label="Search mail"
className="text-sm"
/>
</InputGroup>
<div className="flex items-center justify-between gap-2">
<Tabs
value={filter}
onValueChange={(v) => setFilter(v as "all" | "unread")}
className="w-fit"
>
<TabsList className="h-7">
<TabsTrigger
value="all"
className="px-2 font-mono text-[10px] uppercase tracking-[0.08em]"
>
All
</TabsTrigger>
<TabsTrigger
value="unread"
className="px-2 font-mono text-[10px] uppercase tracking-[0.08em]"
>
Unread
</TabsTrigger>
</TabsList>
</Tabs>
<span className="font-mono text-[10px] tabular-nums uppercase tracking-[0.08em] text-muted-foreground">
{visible.length} of {CONVERSATIONS.length}
</span>
</div>
</div>
<Separator />
<ul className="flex-1 overflow-y-auto">
{visible.map((c) => {
const active = c.id === selected.id;
const unread = isUnread(c);
return (
<li key={c.id}>
<button
type="button"
onClick={() => openConversation(c.id)}
aria-current={active ? "true" : undefined}
className={cn(
"flex w-full flex-col gap-0.5 border-b border-border px-4 py-3 text-start transition-colors",
active ? "bg-accent/70" : "hover:bg-accent/40",
)}
>
<span className="flex items-center gap-2">
{unread && (
<span
aria-hidden
className="size-1.5 shrink-0 rounded-full bg-foreground"
/>
)}
<span
className={cn(
"truncate text-sm",
unread ? "font-semibold" : "font-medium",
)}
>
{c.sender}
</span>
<span className="ms-auto shrink-0 font-mono text-[10px] tabular-nums uppercase tracking-[0.08em] text-muted-foreground">
{c.time}
</span>
</span>
<span className="truncate text-xs text-foreground">
{c.subject}
</span>
<span className="truncate text-xs text-muted-foreground">
{c.preview}…
</span>
</button>
</li>
);
})}
{visible.length === 0 && (
<li className="px-4 py-10 text-center text-xs text-muted-foreground">
No conversations match “{query.trim()}”
</li>
)}
</ul>
</section>
<section
aria-label="Conversation"
className="flex min-w-0 flex-1 flex-col"
>
<div className="flex h-14 shrink-0 items-center justify-between gap-3 border-b border-border px-4 sm:px-6">
<h2 className="truncate text-sm font-medium tracking-[-0.01em]">
{selected.subject}
</h2>
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={toggleStar}
aria-pressed={isStarred}
aria-label={
isStarred ? "Unstar conversation" : "Star conversation"
}
>
<Star className={cn("size-4", isStarred && "fill-current")} />
</Button>
<Button
variant="ghost"
size="icon"
className="size-8"
aria-label="Archive conversation"
>
<Archive className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-8"
aria-label="Delete conversation"
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
<div className="flex items-center gap-3 border-b border-border px-4 py-3 sm:px-6">
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-muted font-mono text-xs font-medium">
{selected.initials}
</span>
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">
{selected.sender}
</span>
<span className="truncate font-mono text-[11px] text-muted-foreground">
{selected.email} · to you
</span>
</div>
</div>
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-5 sm:px-6">
{thread.map((m, i) => (
<div
key={`${m.from}-${i}`}
className={cn(
"flex max-w-xl flex-col gap-2 rounded-md border border-border p-4",
m.from === "You" ? "self-end bg-accent/50" : "bg-card/40",
)}
>
<div className="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">
{m.initials}
</span>
<span className="text-xs font-medium">{m.from}</span>
<span className="ms-auto font-mono text-[10px] tabular-nums uppercase tracking-[0.08em] text-muted-foreground">
{m.time}
</span>
</div>
<p className="text-sm text-muted-foreground">{m.body}</p>
</div>
))}
</div>
<div className="flex flex-col gap-2 border-t border-border p-4 sm:px-6">
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendReply();
}
}}
placeholder={`Reply to ${selected.sender}…`}
aria-label={`Reply to ${selected.sender}`}
className="min-h-20 resize-none"
/>
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">
Enter to send · shift+enter for a new line
</span>
<Button size="sm" onClick={sendReply} disabled={!draft.trim()}>
Send
<SendHorizonal className="size-3.5" />
</Button>
</div>
</div>
</section>
</div>
);
}
Dependencies
shadcn registry
badgebuttoninput-groupseparatortabstextareatooltip
npm
lucide-react