Skip to content
6

App Shell 3

Split-pane inbox shell: icon rail with unread indicator, a searchable conversation listbox you can walk with the arrow keys, and a reading pane where star, archive and delete all work, with undo. Collapses to one pane on phones with a back button, and the composer sends on ⌘+Enter so Enter still makes a new line.

Preview

Installation

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

Code

components/blocks/app-shell-03.tsx
'use client';

import * as React from 'react';
import {
  Archive,
  ChevronLeft,
  Inbox,
  Search,
  Send,
  SendHorizonal,
  Settings,
  Star,
  Trash2,
  Undo2,
  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 {
  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 { Separator } from '@/registry/hirael/bases/radix/ui/separator';
import { Tabs, TabsList, TabsTrigger } from '@/registry/hirael/bases/radix/ui/tabs';
import { Textarea } from '@/registry/hirael/bases/radix/ui/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/registry/hirael/bases/radix/ui/tooltip';

interface Message {
  id: string;
  from: string;
  initials: string;
  time: string;
  body: string;
}

interface 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: [
      {
        id: 'design-review-1',
        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?",
      },
      {
        id: 'design-review-2',
        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: [
      {
        id: 'invoice-april-1',
        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: [
      {
        id: 'launch-checklist-1',
        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.',
      },
      {
        id: 'launch-checklist-2',
        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: [
      {
        id: 'support-export-1',
        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: [
      {
        id: 'onboarding-feedback-1',
        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: [
      {
        id: 'offsite-dates-1',
        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: [
      {
        id: 'security-rotation-1',
        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' },
];

interface Removal {
  id: string;
  kind: 'archived' | 'deleted';
}

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

const AppShell03 = () => {
  const [selectedId, setSelectedId] = React.useState<string | null>(CONVERSATIONS[0].id);
  const [readIds, setReadIds] = React.useState<readonly string[]>([]);
  const [starred, setStarred] = React.useState<readonly string[]>(['launch-checklist']);
  const [removedIds, setRemovedIds] = React.useState<readonly string[]>([]);
  const [lastRemoval, setLastRemoval] = React.useState<Removal | null>(null);
  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('');
  // On phones the list and the reading pane share the viewport, so only one
  // of them is on screen at a time.
  const [mobilePane, setMobilePane] = React.useState<'list' | 'thread'>('list');

  const optionRefs = React.useRef(new Map<string, HTMLLIElement>());

  const isUnread = React.useCallback((c: Conversation) => !!c.unread && !readIds.includes(c.id), [readIds]);

  const inbox = React.useMemo(() => CONVERSATIONS.filter((c) => !removedIds.includes(c.id)), [removedIds]);

  const unreadCount = inbox.filter(isUnread).length;

  const visible = React.useMemo(() => {
    const normalized = query.trim().toLowerCase();
    return inbox.filter((c) => {
      if (filter === 'unread' && !isUnread(c)) return false;
      if (!normalized) return true;
      return c.sender.toLowerCase().includes(normalized) || c.subject.toLowerCase().includes(normalized);
    });
  }, [inbox, filter, query, isUnread]);

  const selected = inbox.find((c) => c.id === selectedId) ?? null;
  const thread = selected ? [...selected.thread, ...(replies[selected.id] ?? [])] : [];
  const isStarred = selected ? starred.includes(selected.id) : false;

  const openConversation = React.useCallback((id: string) => {
    setSelectedId(id);
    setDraft('');
    setReadIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
  }, []);

  /** Arrow keys walk the list the way every mail client does. */
  const onListKeyDown = (event: React.KeyboardEvent<HTMLUListElement>) => {
    const keys = ['ArrowDown', 'ArrowUp', 'Home', 'End'];
    if (!keys.includes(event.key) || visible.length === 0) return;
    event.preventDefault();

    const current = visible.findIndex((c) => c.id === selectedId);
    const last = visible.length - 1;
    const next =
      event.key === 'Home'
        ? 0
        : event.key === 'End'
          ? last
          : event.key === 'ArrowDown'
            ? Math.min(last, current < 0 ? 0 : current + 1)
            : Math.max(0, current < 0 ? 0 : current - 1);

    const target = visible[next];
    openConversation(target.id);
    optionRefs.current.get(target.id)?.focus();
  };

  const toggleStar = () => {
    if (!selected) return;
    setStarred((prev) => (prev.includes(selected.id) ? prev.filter((s) => s !== selected.id) : [...prev, selected.id]));
  };

  /** Removing selects the neighbour below, then above, so focus never dies. */
  const remove = (kind: Removal['kind']) => {
    if (!selected) return;
    const index = visible.findIndex((c) => c.id === selected.id);
    const neighbour = visible[index + 1] ?? visible[index - 1] ?? null;

    setRemovedIds((prev) => [...prev, selected.id]);
    setLastRemoval({ id: selected.id, kind });
    setSelectedId(neighbour?.id ?? null);
    setDraft('');
    if (!neighbour) setMobilePane('list');
  };

  const undoRemoval = () => {
    if (!lastRemoval) return;
    setRemovedIds((prev) => prev.filter((id) => id !== lastRemoval.id));
    setSelectedId(lastRemoval.id);
    setLastRemoval(null);
  };

  const sendReply = () => {
    const body = draft.trim();
    if (!body || !selected) return;
    const existing = replies[selected.id] ?? [];
    setReplies((prev) => ({
      ...prev,
      [selected.id]: [
        ...existing,
        {
          id: `${selected.id}-reply-${existing.length + 1}`,
          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"
                variant="ghost"
                size="icon"
                aria-label={item.current && unreadCount > 0 ? `${item.label} · ${unreadCount} unread` : item.label}
                aria-current={item.current ? 'page' : undefined}
                className={cn('relative', item.current ? 'bg-accent text-foreground' : 'text-muted-foreground')}
              >
                <item.icon className="size-4" aria-hidden />
                {item.current && unreadCount > 0 && (
                  <span aria-hidden 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" variant="ghost" size="icon" aria-label="Settings" className="text-muted-foreground">
                <Settings className="size-4" aria-hidden />
              </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={cn(
          // Full width next to the rail on phones, a fixed column from md up.
          'min-w-0 flex-1 flex-col border-e border-border md:flex md:w-80 md:flex-none',
          mobilePane === 'thread' ? 'hidden' : '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" aria-hidden />
            </InputGroupAddon>
            <InputGroupInput
              type="search"
              value={query}
              onChange={(e) => setQuery(e.target.value)}
              onKeyDown={(e) => {
                if (e.key === 'Escape' && query) {
                  e.preventDefault();
                  setQuery('');
                }
              }}
              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
              dir="ltr"
              aria-live="polite"
              className="font-mono text-[10px] uppercase tracking-[0.08em] tabular-nums text-muted-foreground"
            >
              {visible.length} of {inbox.length}
            </span>
          </div>
        </div>
        <Separator />

        {lastRemoval && (
          <div className="flex items-center justify-between gap-2 border-b border-border bg-muted/40 px-4 py-2">
            <span className="truncate font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground">
              conversation {lastRemoval.kind}
            </span>
            <Button variant="ghost" size="sm" className="h-6 shrink-0 px-2" onClick={undoRemoval}>
              <Undo2 className="size-3 rtl:rotate-180" aria-hidden />
              Undo
            </Button>
          </div>
        )}

        {visible.length === 0 ? (
          <Empty className="border-0">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <Inbox />
              </EmptyMedia>
              <EmptyTitle>{query.trim() ? 'No conversations match' : 'Inbox zero'}</EmptyTitle>
              <EmptyDescription>
                {query.trim()
                  ? `Nothing matches “${query.trim()}”.`
                  : filter === 'unread'
                    ? 'Everything here has been read.'
                    : 'Nothing left in this view.'}
              </EmptyDescription>
            </EmptyHeader>
            {query.trim() && (
              <EmptyContent>
                <Button variant="outline" size="sm" onClick={() => setQuery('')}>
                  Clear search
                </Button>
              </EmptyContent>
            )}
          </Empty>
        ) : (
          <ul
            role="listbox"
            aria-label="Conversations"
            aria-orientation="vertical"
            onKeyDown={onListKeyDown}
            className="flex-1 overflow-y-auto"
          >
            {visible.map((c) => {
              const active = c.id === selectedId;
              const unread = isUnread(c);
              return (
                <li
                  key={c.id}
                  ref={(node) => {
                    if (node) optionRefs.current.set(c.id, node);
                    else optionRefs.current.delete(c.id);
                  }}
                  role="option"
                  aria-selected={active}
                  tabIndex={active ? 0 : -1}
                  onClick={() => {
                    openConversation(c.id);
                    setMobilePane('thread');
                  }}
                  onKeyDown={(e) => {
                    if (e.key !== 'Enter' && e.key !== ' ') return;
                    e.preventDefault();
                    openConversation(c.id);
                    setMobilePane('thread');
                  }}
                  className={cn(
                    'flex cursor-pointer flex-col gap-0.5 border-b border-border px-4 py-3 text-start transition-colors outline-none',
                    'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
                    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}
                      {unread && <span className="sr-only"> (unread)</span>}
                    </span>
                    <span className="ms-auto shrink-0 font-mono text-[10px] uppercase tracking-[0.08em] tabular-nums 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>
                </li>
              );
            })}
          </ul>
        )}
      </section>

      <section
        aria-label="Conversation"
        className={cn('min-w-0 flex-1 flex-col md:flex', mobilePane === 'list' ? 'hidden' : 'flex')}
      >
        {selected ? (
          <>
            <div className="flex h-14 shrink-0 items-center justify-between gap-3 border-b border-border px-4 sm:px-6">
              <div className="flex min-w-0 items-center gap-2">
                <Button
                  variant="ghost"
                  size="icon"
                  className="size-8 shrink-0 md:hidden"
                  onClick={() => setMobilePane('list')}
                  aria-label="Back to conversations"
                >
                  <ChevronLeft className="size-4 rtl:rotate-180" aria-hidden />
                </Button>
                <h2 className="truncate text-sm font-medium tracking-[-0.01em]">{selected.subject}</h2>
              </div>
              <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')} aria-hidden />
                </Button>
                <Button
                  variant="ghost"
                  size="icon"
                  className="size-8"
                  onClick={() => remove('archived')}
                  aria-label="Archive conversation"
                >
                  <Archive className="size-4" aria-hidden />
                </Button>
                <Button
                  variant="ghost"
                  size="icon"
                  className="size-8"
                  onClick={() => remove('deleted')}
                  aria-label="Delete conversation"
                >
                  <Trash2 className="size-4" aria-hidden />
                </Button>
              </div>
            </div>

            <div className="flex items-center gap-3 border-b border-border px-4 py-3 sm:px-6">
              <span
                aria-hidden
                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) => (
                <article
                  key={m.id}
                  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
                      aria-hidden
                      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] uppercase tracking-[0.08em] tabular-nums text-muted-foreground">
                      {m.time}
                    </span>
                  </div>
                  <p className="text-sm text-muted-foreground">{m.body}</p>
                </article>
              ))}
            </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) => {
                  // Enter writes a new line in a mail composer; ⌘/Ctrl sends.
                  if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
                    e.preventDefault();
                    sendReply();
                  }
                }}
                placeholder={`Reply to ${selected.sender}…`}
                aria-label={`Reply to ${selected.sender}`}
                aria-keyshortcuts="Meta+Enter Control+Enter"
                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
                </span>
                <Button size="sm" onClick={sendReply} disabled={!draft.trim()}>
                  Send
                  <SendHorizonal className="size-3.5 rtl:rotate-180" aria-hidden />
                </Button>
              </div>
            </div>
          </>
        ) : (
          <Empty className="flex-1 border-0">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <Inbox />
              </EmptyMedia>
              <EmptyTitle>Nothing selected</EmptyTitle>
              <EmptyDescription>Pick a conversation from the list to read it here.</EmptyDescription>
            </EmptyHeader>
            {lastRemoval && (
              <EmptyContent>
                <Button variant="outline" size="sm" onClick={undoRemoval}>
                  <Undo2 className="size-3.5 rtl:rotate-180" aria-hidden />
                  Undo {lastRemoval.kind === 'archived' ? 'archive' : 'delete'}
                </Button>
              </EmptyContent>
            )}
          </Empty>
        )}
      </section>
    </div>
  );
};

export default AppShell03;

Dependencies

shadcn registry

badgebuttonemptyinput-groupseparatortabstextareatooltip

npm

lucide-react