# Email Client

> A three-pane mail app with folders, a searchable message list, a reading pane, quick reply and a composer; list and reader swap on mobile.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/templates/email-client
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/email-client.json
```

Adds the page at `app/email-client/page.tsx` together with the components below. Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Components



## Dependencies

```bash
npm install lucide-react
```

## Source

```tsx title="app/email-client/page.tsx"
"use client"

import * as React from "react"
import {
  Archive,
  ArrowLeft,
  FileText,
  Forward,
  Hexagon,
  Inbox,
  MailOpen,
  Reply,
  Search,
  Send,
  SquarePen,
  Star,
  Trash2,
  X,
} from "lucide-react"

import { cn } from "@/lib/utils"

type Box = "inbox" | "sent" | "drafts" | "archive"
type Folder = Box | "starred"

interface Mail {
  id: string
  box: Box
  starred: boolean
  unread: boolean
  /** Sender for received mail, recipient for sent mail and drafts */
  name: string
  email: string
  subject: string
  time: string
  body: string[]
  tone: string
  outgoing?: boolean
}

interface Draft {
  key: number
  to: string
  subject: string
  body: string
}

const folders: { id: Folder; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
  { id: "inbox", label: "Inbox", icon: Inbox },
  { id: "starred", label: "Starred", icon: Star },
  { id: "sent", label: "Sent", icon: Send },
  { id: "drafts", label: "Drafts", icon: FileText },
  { id: "archive", label: "Archive", icon: Archive },
]

const initialMails: Mail[] = [
  {
    id: "m1",
    box: "inbox",
    starred: true,
    unread: false,
    name: "Priya Raman",
    email: "priya@northwind.co",
    subject: "Q4 roadmap review: slides attached",
    time: "9:41 AM",
    tone: "from-indigo-500 to-violet-500",
    body: [
      "Hi Ada,",
      "Thanks for walking us through the roadmap last week. I pulled the feedback from the product and support teams into a single deck. The headline is that customers want faster preview deployments more than any new feature, so I would like to move that work to the top of the quarter.",
      "Could you look at slides 6 to 9 before Thursday? Those cover the capacity assumptions and I am least sure about them. If the numbers hold up we can lock the plan in our Friday sync.",
      "Best,\nPriya",
    ],
  },
  {
    id: "m2",
    box: "inbox",
    starred: false,
    unread: true,
    name: "Marcus Lee",
    email: "marcus@hexa.dev",
    subject: "Deploy window moved to Thursday",
    time: "8:15 AM",
    tone: "from-emerald-500 to-teal-500",
    body: [
      "Morning,",
      "Heads up that the production deploy window for the billing service moved from Wednesday evening to Thursday 10:00 UTC. The database migration needs one more rehearsal on staging and the on-call engineer is out until then.",
      "Nothing changes on your side unless you have a release depending on the new invoice endpoints. If you do, reply here and I will add it to the checklist.",
      "Marcus",
    ],
  },
  {
    id: "m3",
    box: "inbox",
    starred: false,
    unread: true,
    name: "Sofia Alvarez",
    email: "sofia@brightlabs.io",
    subject: "Invoice #2043 is ready",
    time: "Yesterday",
    tone: "from-amber-500 to-orange-500",
    body: [
      "Hello Ada,",
      "Your invoice for the October design sprint is attached and due in 14 days. It covers 38 hours of design work and the two extra review rounds we agreed on.",
      "Let me know if your finance team needs a purchase order number on it and I will reissue it the same day.",
      "Thank you,\nSofia",
    ],
  },
  {
    id: "m4",
    box: "inbox",
    starred: false,
    unread: false,
    name: "Hexa Billing",
    email: "billing@hexa.dev",
    subject: "Your October usage summary",
    time: "Yesterday",
    tone: "from-sky-500 to-blue-600",
    body: [
      "Your workspace used 4.2 TB of bandwidth and 3,800 build minutes in October, which is within your Pro plan limits.",
      "Storage grew by 6 GB to a total of 79 GB. You can review the breakdown by project in Settings under Usage.",
    ],
  },
  {
    id: "m5",
    box: "inbox",
    starred: false,
    unread: true,
    name: "Jonas Weber",
    email: "jonas@kestrel.studio",
    subject: "Intro: design partnership",
    time: "Mon",
    tone: "from-rose-500 to-pink-500",
    body: [
      "Hi Ada,",
      "Grace suggested I reach out. We run a small studio that builds component libraries for fintech teams, and we would love to explore a partnership around your design tokens work.",
      "Would you have 20 minutes next week for an intro call? I am flexible on time zones.",
      "Warmly,\nJonas",
    ],
  },
  {
    id: "m6",
    box: "inbox",
    starred: false,
    unread: false,
    name: "Aiko Tanaka",
    email: "aiko@orbit.jp",
    subject: "Re: Onboarding copy feedback",
    time: "Mon",
    tone: "from-fuchsia-500 to-purple-600",
    body: [
      "Thanks for the quick turnaround. I applied your edits to steps two and three and shortened the welcome message to two sentences.",
      "One open question: should the checklist mention the free trial length, or leave that to the billing page? I lean towards leaving it out.",
      "Aiko",
    ],
  },
  {
    id: "m7",
    box: "inbox",
    starred: true,
    unread: false,
    name: "Hexa Team",
    email: "team@hexa.dev",
    subject: "Welcome to the new inbox",
    time: "Nov 1",
    tone: "from-violet-500 to-indigo-600",
    body: [
      "Welcome aboard. Your inbox now supports keyboard shortcuts, fast search and a quick-reply box under every message.",
      "Star the messages you want to come back to and archive the rest. Everything stays searchable, even after it leaves your inbox.",
    ],
  },
  {
    id: "m8",
    box: "inbox",
    starred: false,
    unread: true,
    name: "Daniel Okafor",
    email: "daniel@lumen.app",
    subject: "Coffee next week?",
    time: "Oct 30",
    tone: "from-cyan-500 to-sky-600",
    body: [
      "Ada, I will be in town Tuesday and Wednesday for the infrastructure meetup. Are you free for a coffee? I would love to hear how the migration to the new build pipeline went.",
      "Daniel",
    ],
  },
  {
    id: "s1",
    box: "sent",
    starred: false,
    unread: false,
    outgoing: true,
    name: "Priya Raman",
    email: "priya@northwind.co",
    subject: "Re: Q4 roadmap review",
    time: "Mon",
    tone: "from-indigo-500 to-violet-500",
    body: ["Thanks Priya. I will go through the capacity slides tonight and send comments tomorrow morning."],
  },
  {
    id: "s2",
    box: "sent",
    starred: false,
    unread: false,
    outgoing: true,
    name: "Marcus Lee",
    email: "marcus@hexa.dev",
    subject: "Staging checklist for the billing release",
    time: "Oct 29",
    tone: "from-emerald-500 to-teal-500",
    body: [
      "Marcus, here is the checklist I use before every billing deploy: confirm webhooks are paused, run the migration dry run, and keep the rollback script open in a second window.",
    ],
  },
  {
    id: "d1",
    box: "drafts",
    starred: false,
    unread: false,
    outgoing: true,
    name: "Product team",
    email: "product@hexa.dev",
    subject: "Launch announcement: first draft",
    time: "Oct 28",
    tone: "from-slate-500 to-slate-700",
    body: ["Hi all, we ship the new preview deployments on the 12th. Here is the announcement copy for review. Please leave comments directly in the doc by Friday."],
  },
  {
    id: "a1",
    box: "archive",
    starred: false,
    unread: false,
    name: "Hexa Billing",
    email: "billing@hexa.dev",
    subject: "Receipt: annual domain renewal",
    time: "Oct 12",
    tone: "from-sky-500 to-blue-600",
    body: ["Thanks for your payment of $18.00. Your domain hexa.dev is renewed until October 2027."],
  },
  {
    id: "a2",
    box: "archive",
    starred: true,
    unread: false,
    name: "Grace Hopper",
    email: "grace@hexa.dev",
    subject: "Team offsite logistics",
    time: "Oct 3",
    tone: "from-orange-500 to-red-500",
    body: [
      "Flights are booked for everyone. We meet at the hotel lobby on the 14th at 4 pm and the first workshop starts the next morning at 9:30. Dietary requirements are in the shared sheet.",
    ],
  },
]

const initials = (name: string) =>
  name
    .split(/\s+/)
    .map((part) => part[0])
    .join("")
    .slice(0, 2)
    .toUpperCase()

function Avatar({ name, tone, className }: { name: string; tone: string; className?: string }) {
  return (
    <span
      aria-hidden
      className={cn(
        "flex size-10 shrink-0 items-center justify-center rounded-full bg-linear-to-br text-xs font-semibold text-white",
        tone,
        className
      )}
    >
      {initials(name)}
    </span>
  )
}

const iconButton =
  "inline-flex size-9 items-center justify-center gap-1.5 rounded-lg text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:pointer-events-none disabled:opacity-40 sm:w-auto sm:px-2.5"

function Composer({
  draft,
  onSend,
  onDiscard,
}: {
  draft: Draft
  onSend: (draft: Draft) => void
  onDiscard: () => void
}) {
  const id = React.useId()
  const [to, setTo] = React.useState(draft.to)
  const [subject, setSubject] = React.useState(draft.subject)
  const [body, setBody] = React.useState(draft.body)
  const canSend = to.trim().length > 0 && body.trim().length > 0

  return (
    <form
      aria-label="New message"
      onSubmit={(event) => {
        event.preventDefault()
        if (canSend) onSend({ ...draft, to: to.trim(), subject: subject.trim(), body })
      }}
      className="flex min-h-0 flex-1 flex-col"
    >
      <div className="flex items-center justify-between gap-3 border-b px-4 py-3">
        <h2 className="text-base font-semibold">New message</h2>
        <button type="button" onClick={onDiscard} aria-label="Discard draft" className={iconButton}>
          <X className="size-4" />
        </button>
      </div>
      <div className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-4">
        <div className="flex items-center gap-3 border-b pb-2">
          <label htmlFor={`${id}-to`} className="w-14 shrink-0 text-sm text-muted-foreground">
            To
          </label>
          <input
            id={`${id}-to`}
            type="email"
            value={to}
            onChange={(event) => setTo(event.target.value)}
            placeholder="name@company.com"
            autoComplete="off"
            className="h-9 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/70"
          />
        </div>
        <div className="flex items-center gap-3 border-b pb-2">
          <label htmlFor={`${id}-subject`} className="w-14 shrink-0 text-sm text-muted-foreground">
            Subject
          </label>
          <input
            id={`${id}-subject`}
            value={subject}
            onChange={(event) => setSubject(event.target.value)}
            placeholder="What is this about?"
            className="h-9 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/70"
          />
        </div>
        <label htmlFor={`${id}-body`} className="sr-only">
          Message
        </label>
        <textarea
          id={`${id}-body`}
          value={body}
          onChange={(event) => setBody(event.target.value)}
          placeholder="Write your message…"
          className="min-h-48 flex-1 resize-none bg-transparent py-3 text-sm leading-relaxed outline-none placeholder:text-muted-foreground/70"
        />
      </div>
      <div className="flex items-center justify-between gap-3 border-t px-4 py-3">
        <button type="button" onClick={onDiscard} className="h-9 rounded-lg px-3 text-sm text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none">
          Discard
        </button>
        <button
          type="submit"
          disabled={!canSend}
          className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none disabled:opacity-50"
        >
          <Send className="size-4" />
          Send
        </button>
      </div>
    </form>
  )
}

function ReplyBox({
  mail,
  onSend,
  inputRef,
}: {
  mail: Mail
  onSend: (text: string) => void
  inputRef: React.Ref<HTMLTextAreaElement>
}) {
  const [text, setText] = React.useState("")
  const first = mail.name.split(" ")[0]

  return (
    <form
      aria-label="Quick reply"
      onSubmit={(event) => {
        event.preventDefault()
        if (!text.trim()) return
        onSend(text.trim())
        setText("")
      }}
      className="mt-8 rounded-2xl border bg-card p-3 shadow-xs focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/20"
    >
      <textarea
        ref={inputRef}
        value={text}
        onChange={(event) => setText(event.target.value)}
        aria-label="Quick reply"
        placeholder={`Reply to ${first}…`}
        rows={3}
        className="block w-full resize-none bg-transparent px-1 py-1 text-sm leading-relaxed outline-none placeholder:text-muted-foreground/70"
      />
      <div className="mt-2 flex items-center justify-between gap-3">
        <p className="min-w-0 truncate text-xs text-muted-foreground">To {mail.email}</p>
        <button
          type="submit"
          disabled={!text.trim()}
          className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none disabled:opacity-50"
        >
          <Send className="size-3.5" />
          Send reply
        </button>
      </div>
    </form>
  )
}

function Reader({
  mail,
  onBack,
  onToggleStar,
  onArchive,
  onDelete,
  onForward,
  onReply,
  replyRef,
}: {
  mail: Mail
  onBack: () => void
  onToggleStar: () => void
  onArchive: () => void
  onDelete: () => void
  onForward: () => void
  onReply: (text: string) => void
  replyRef: React.RefObject<HTMLTextAreaElement | null>
}) {
  return (
    <>
      <div className="flex items-center gap-1 border-b px-3 py-2.5">
        <button type="button" onClick={onBack} aria-label="Back to messages" className={cn(iconButton, "lg:hidden")}>
          <ArrowLeft className="size-4" />
        </button>
        <h2 className="min-w-0 flex-1 truncate px-1 text-sm font-semibold sm:text-base">{mail.subject}</h2>
        <div className="flex shrink-0 items-center">
          <button
            type="button"
            onClick={() => replyRef.current?.focus()}
            aria-label="Reply"
            className={iconButton}
          >
            <Reply className="size-4" />
            <span className="hidden xl:inline">Reply</span>
          </button>
          <button type="button" onClick={onForward} aria-label="Forward" className={iconButton}>
            <Forward className="size-4" />
            <span className="hidden xl:inline">Forward</span>
          </button>
          <button
            type="button"
            onClick={onToggleStar}
            aria-label={mail.starred ? "Remove star" : "Star"}
            aria-pressed={mail.starred}
            className={cn(iconButton, mail.starred && "text-amber-500 hover:text-amber-500")}
          >
            <Star className={cn("size-4", mail.starred && "fill-current")} />
          </button>
          <button type="button" onClick={onArchive} disabled={mail.box === "archive"} aria-label="Archive" className={iconButton}>
            <Archive className="size-4" />
          </button>
          <button type="button" onClick={onDelete} aria-label="Delete" className={cn(iconButton, "hover:bg-destructive/10 hover:text-destructive")}>
            <Trash2 className="size-4" />
          </button>
        </div>
      </div>

      <div className="min-h-0 flex-1 overflow-y-auto">
        <article aria-label={`Message from ${mail.name}`} className="mx-auto max-w-2xl px-4 py-6 sm:px-8">
          <header className="flex items-start gap-3">
            <Avatar name={mail.name} tone={mail.tone} className="size-11" />
            <div className="min-w-0 flex-1">
              <p className="truncate text-sm font-semibold">
                {mail.outgoing ? "To: " : ""}
                {mail.name}
              </p>
              <p className="truncate text-xs text-muted-foreground">
                {mail.outgoing ? mail.email : `${mail.email} · to me`}
              </p>
            </div>
            <time className="shrink-0 text-xs text-muted-foreground">{mail.time}</time>
          </header>
          <div className="mt-6 flex flex-col gap-4 text-sm leading-relaxed">
            {mail.body.map((paragraph, index) => (
              <p key={index} className="whitespace-pre-line">
                {paragraph}
              </p>
            ))}
          </div>
          <ReplyBox key={mail.id} mail={mail} onSend={onReply} inputRef={replyRef} />
        </article>
      </div>
    </>
  )
}

export default function EmailClient() {
  const searchId = React.useId()
  const [mails, setMails] = React.useState<Mail[]>(initialMails)
  const [folder, setFolder] = React.useState<Folder>("inbox")
  const [query, setQuery] = React.useState("")
  const [selectedId, setSelectedId] = React.useState<string | null>("m1")
  const [reading, setReading] = React.useState(false)
  const [draft, setDraft] = React.useState<Draft | null>(null)
  const [notice, setNotice] = React.useState("")
  const replyRef = React.useRef<HTMLTextAreaElement>(null)
  const counter = React.useRef(0)

  const needle = query.trim().toLowerCase()
  const visible = mails.filter((mail) => {
    if (folder === "starred" ? !mail.starred : mail.box !== folder) return false
    if (!needle) return true
    return [mail.name, mail.subject, mail.body.join(" ")].some((text) => text.toLowerCase().includes(needle))
  })
  const selected = visible.find((mail) => mail.id === selectedId) ?? null
  const showReader = reading || draft !== null

  const badge = (id: Folder) => {
    if (id === "inbox") return mails.filter((m) => m.box === "inbox" && m.unread).length
    if (id === "starred") return mails.filter((m) => m.starred && m.unread).length
    if (id === "drafts") return mails.filter((m) => m.box === "drafts").length
    return 0
  }

  const update = (id: string, patch: Partial<Mail>) =>
    setMails((all) => all.map((mail) => (mail.id === id ? { ...mail, ...patch } : mail)))

  const open = (id: string) => {
    update(id, { unread: false })
    setSelectedId(id)
    setReading(true)
    setDraft(null)
    setNotice("")
  }

  const goToFolder = (next: Folder) => {
    setFolder(next)
    setSelectedId(null)
    setReading(false)
    setDraft(null)
    setNotice("")
  }

  const newId = () => `n${++counter.current}`

  const compose = (seed?: Partial<Draft>) => {
    setDraft({ key: ++counter.current, to: "", subject: "", body: "", ...seed })
    setReading(true)
  }

  const finishRemoval = (message: string) => {
    setSelectedId(null)
    setReading(false)
    setNotice(message)
  }

  const send = (next: Draft) => {
    setMails((all) => [
      {
        id: newId(),
        box: "sent",
        starred: false,
        unread: false,
        outgoing: true,
        name: next.to,
        email: next.to,
        subject: next.subject || "(no subject)",
        time: "Just now",
        tone: "from-indigo-500 to-violet-500",
        body: [next.body],
      },
      ...all,
    ])
    setDraft(null)
    setReading(false)
    setNotice(`Message sent to ${next.to}`)
  }

  const reply = (to: Mail, text: string) => {
    setMails((all) => [
      {
        id: newId(),
        box: "sent",
        starred: false,
        unread: false,
        outgoing: true,
        name: to.name,
        email: to.email,
        subject: to.subject.startsWith("Re:") ? to.subject : `Re: ${to.subject}`,
        time: "Just now",
        tone: to.tone,
        body: [text],
      },
      ...all,
    ])
    setNotice(`Reply sent to ${to.name}`)
  }

  return (
    <div className="flex h-svh min-h-[540px] flex-col overflow-hidden bg-background text-foreground lg:flex-row">
      <aside
        aria-label="Mail folders"
        className={cn("shrink-0 flex-col border-b bg-muted/30 lg:flex lg:w-60 lg:border-r lg:border-b-0", showReader ? "hidden" : "flex")}
      >
        <div className="flex items-center justify-between gap-3 px-4 py-3 lg:h-14 lg:border-b">
          <span className="flex items-center gap-2 font-semibold">
            <Hexagon className="size-5 fill-primary text-primary" /> Hexa Mail
          </span>
          <button
            type="button"
            onClick={() => compose()}
            className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none lg:hidden"
          >
            <SquarePen className="size-4" />
            Compose
          </button>
        </div>
        <nav aria-label="Folders" className="px-2 pb-2 lg:flex-1 lg:p-3">
          <button
            type="button"
            onClick={() => compose()}
            className="mb-3 hidden h-10 w-full items-center justify-center gap-2 rounded-lg bg-primary text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none lg:flex"
          >
            <SquarePen className="size-4" />
            Compose
          </button>
          <ul className="relative flex gap-1 overflow-x-auto lg:flex-col lg:overflow-visible">
            {folders.map(({ id, label, icon: Icon }) => {
              const count = badge(id)
              const active = folder === id && !draft
              return (
                <li key={id} className="shrink-0 lg:shrink">
                  <button
                    type="button"
                    onClick={() => goToFolder(id)}
                    aria-current={active ? "page" : undefined}
                    className="flex h-9 w-full items-center gap-2.5 rounded-lg px-3 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none aria-[current=page]:bg-accent aria-[current=page]:font-medium aria-[current=page]:text-foreground"
                  >
                    <Icon className="size-4 shrink-0" />
                    <span className="flex-1 text-left">{label}</span>
                    {count > 0 && (
                      <span className="rounded-full bg-primary px-1.5 text-[11px] leading-4.5 font-medium text-primary-foreground tabular-nums">
                        <span className="sr-only">{count} {id === "drafts" ? "drafts" : "unread"}</span>
                        <span aria-hidden>{count}</span>
                      </span>
                    )}
                  </button>
                </li>
              )
            })}
          </ul>
        </nav>
        <div className="hidden items-center gap-3 border-t p-3 lg:flex">
          <Avatar name="Ada Lovelace" tone="from-indigo-500 to-violet-500" className="size-8" />
          <div className="min-w-0">
            <p className="truncate text-sm font-medium">Ada Lovelace</p>
            <p className="truncate text-xs text-muted-foreground">ada@hexa.dev</p>
          </div>
        </div>
      </aside>

      <div className="flex min-h-0 min-w-0 flex-1">
        <section
          aria-label="Messages"
          className={cn("min-h-0 min-w-0 flex-col border-r lg:flex lg:w-80 lg:flex-none xl:w-96", showReader ? "hidden" : "flex flex-1")}
        >
          <div className="flex flex-col gap-3 border-b p-4">
            <div className="flex items-baseline justify-between gap-3">
              <h1 className="text-lg font-semibold capitalize">{folder}</h1>
              <p className="text-xs text-muted-foreground tabular-nums">
                {visible.length} {visible.length === 1 ? "message" : "messages"}
              </p>
            </div>
            <form role="search" onSubmit={(event) => event.preventDefault()} className="relative">
              <label htmlFor={searchId} className="sr-only">
                Search mail
              </label>
              <Search aria-hidden className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
              <input
                id={searchId}
                type="search"
                value={query}
                onChange={(event) => setQuery(event.target.value)}
                placeholder="Search mail"
                autoComplete="off"
                className="h-9 w-full rounded-lg border bg-background pr-3 pl-9 text-sm shadow-xs outline-none transition-[border-color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/20"
              />
            </form>
          </div>
          {visible.length === 0 ? (
            <div className="flex flex-1 flex-col items-center justify-center gap-2 p-8 text-center">
              <MailOpen className="size-8 text-muted-foreground/60" />
              <p className="text-sm font-medium">{needle ? "No matching messages" : "Nothing here"}</p>
              <p className="text-sm text-muted-foreground">
                {needle ? "Try a different sender or keyword." : "Messages you move here will show up in this folder."}
              </p>
            </div>
          ) : (
            <ul className="min-h-0 flex-1 overflow-y-auto">
              {visible.map((mail) => {
                const active = mail.id === selected?.id && !draft
                return (
                  <li key={mail.id} className="border-b">
                    <button
                      type="button"
                      onClick={() => open(mail.id)}
                      aria-current={active ? "true" : undefined}
                      className="relative flex w-full items-start gap-3 py-3 pr-4 pl-5 text-left transition-colors hover:bg-accent/60 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-inset aria-[current=true]:bg-accent"
                    >
                      {mail.unread && (
                        <>
                          <span aria-hidden className="absolute top-5 left-2 size-2 rounded-full bg-primary" />
                          <span className="sr-only">Unread.</span>
                        </>
                      )}
                      <Avatar name={mail.name} tone={mail.tone} />
                      <span className="min-w-0 flex-1">
                        <span className="flex items-baseline justify-between gap-2">
                          <span className={cn("truncate text-sm", mail.unread ? "font-semibold" : "font-medium")}>
                            {mail.outgoing ? "To: " : ""}
                            {mail.name}
                          </span>
                          <span className="flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
                            {mail.starred && <Star aria-label="Starred" className="size-3 fill-amber-500 text-amber-500" />}
                            {mail.time}
                          </span>
                        </span>
                        <span className={cn("block truncate text-sm", mail.unread ? "text-foreground" : "text-muted-foreground")}>{mail.subject}</span>
                        <span className="mt-0.5 block truncate text-xs text-muted-foreground">{mail.body[mail.body.length > 1 ? 1 : 0]}</span>
                      </span>
                    </button>
                  </li>
                )
              })}
            </ul>
          )}
        </section>

        <section
          aria-label="Reading pane"
          className={cn("min-h-0 min-w-0 flex-1 flex-col bg-background lg:flex", showReader ? "flex" : "hidden")}
        >
          {draft ? (
            <Composer key={draft.key} draft={draft} onSend={send} onDiscard={() => { setDraft(null); setReading(false) }} />
          ) : selected ? (
            <Reader
              mail={selected}
              replyRef={replyRef}
              onBack={() => setReading(false)}
              onToggleStar={() => update(selected.id, { starred: !selected.starred })}
              onArchive={() => {
                update(selected.id, { box: "archive" })
                finishRemoval("Conversation archived")
              }}
              onDelete={() => {
                setMails((all) => all.filter((mail) => mail.id !== selected.id))
                finishRemoval("Conversation deleted")
              }}
              onForward={() =>
                compose({
                  subject: `Fwd: ${selected.subject}`,
                  body: `\n\n---------- Forwarded message ----------\nFrom: ${selected.name} <${selected.email}>\n\n${selected.body.join("\n\n")}`,
                })
              }
              onReply={(text) => reply(selected, text)}
            />
          ) : (
            <div className="flex flex-1 flex-col items-center justify-center gap-2 p-8 text-center">
              <MailOpen className="size-10 text-muted-foreground/50" />
              <p className="text-sm font-medium">Select a message to read</p>
              <p className="max-w-xs text-sm text-muted-foreground">Pick a conversation from the list, or compose a new message.</p>
            </div>
          )}
        </section>
      </div>

      <div
        role="status"
        aria-live="polite"
        className={cn(
          notice
            ? "fixed bottom-4 left-1/2 z-20 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center gap-3 rounded-xl bg-foreground py-2.5 pr-2 pl-4 text-sm text-background shadow-lg animate-in fade-in slide-in-from-bottom-2"
            : "sr-only"
        )}
      >
        <span className="min-w-0 truncate">{notice}</span>
        {notice && (
          <button
            type="button"
            onClick={() => setNotice("")}
            aria-label="Dismiss notification"
            className="flex size-7 shrink-0 items-center justify-center rounded-md hover:bg-background/15 focus-visible:ring-2 focus-visible:ring-background focus-visible:outline-none"
          >
            <X className="size-4" />
          </button>
        )}
      </div>
    </div>
  )
}
```
