# Mail 1: three-pane inbox

> A working inbox: folders with an unread count, a searchable message list with stars and keyboard navigation, a reading pane with archive, delete, mark unread and reply, and a compose dialog. One pane at a time on phones.

A shadcn/ui block from the [@ballmac](https://ui.ballmac.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ballmac/mail-1).

- Page, with a live preview: https://shadcn.exchange/r/ballmac/mail-1
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ballmac/mail-1-light-861103dc5e99cf9a.png
- Registry item: https://ui.ballmac.com/r/mail-1.json
- Categories: blocks, mail
- Listed: 2026-10-14, source last changed 2026-10-14

## Install

```bash
npx shadcn@latest add https://ui.ballmac.com/r/mail-1.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `utils`, `https://ui.ballmac.com/r/avatar.json`, `https://ui.ballmac.com/r/button.json`, `https://ui.ballmac.com/r/dialog.json`, `https://ui.ballmac.com/r/input.json`, `https://ui.ballmac.com/r/search-field.json`, `https://ui.ballmac.com/r/select.json`, `https://ui.ballmac.com/r/textarea.json`, `https://ui.ballmac.com/r/state-view.json`, `https://ui.ballmac.com/r/skeleton.json`

## Files

### `registry/ballmac/components/blocks/mail-1/mail-1.tsx`

```tsx
// Ballmac UI: Mail 1. https://ui.ballmac.com/blocks/mail-1
"use client"

import * as React from "react"
import { Archive, ArrowLeft, Inbox, MailOpen, Paperclip, PenSquare, Reply, Send, Star, Trash2 } from "lucide-react"

import { Avatar, AvatarFallback } from "@/components/ballmac/avatar"
import { Button, buttonVariants } from "@/components/ballmac/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ballmac/dialog"
import { Input } from "@/components/ballmac/input"
import { SearchField } from "@/components/ballmac/search-field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ballmac/select"
import { Skeleton } from "@/components/ballmac/skeleton"
import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view"
import { Textarea } from "@/components/ballmac/textarea"
import { cn } from "@/lib/utils"

type Mail1Folder = "inbox" | "sent" | "archive"

type Mail1Message = {
  id: string
  folder: Mail1Folder
  from: { name: string; email: string }
  subject: string
  /** Paragraphs of the message body. */
  body: string[]
  /** When it arrived, in words (e.g. "9:41 AM" or "Yesterday"). */
  time: string
  unread?: boolean
  starred?: boolean
  attachments?: { name: string; size: string }[]
}

type Mail1Props = Omit<React.ComponentProps<"div">, "children"> & StateProps & {
  /** The messages. They seed the inbox when it appears; to load new ones, go through `state="loading"`. */
  messages?: Mail1Message[]
  /** Name shown on messages you send. */
  me?: { name: string; email: string }
  /** Called with the new message when something is sent or replied to. */
  onSend?: (to: string, subject: string, body: string) => void
  /** Height of the frame. */
  height?: string
}

const defaults: Mail1Message[] = [
  {
    id: "m1", folder: "inbox", unread: true, starred: true, time: "9:41 AM",
    from: { name: "Maya Kim", email: "maya@northwind.co" },
    subject: "Q4 budget: can you review before Friday?",
    body: ["Hi Jordan,", "I’ve put the draft Q4 budget in the shared folder. The main change is the extra headcount in support, which moves total spend up about 6%.", "Could you look at the assumptions tab and flag anything that looks off? I’d like to send it to finance on Friday morning.", "Thanks,\nMaya"],
    attachments: [{ name: "Q4-budget-draft.xlsx", size: "184 KB" }],
  },
  {
    id: "m2", folder: "inbox", unread: true, time: "8:15 AM",
    from: { name: "Stripe", email: "receipts@stripe.com" },
    subject: "Your payout of $12,480.00 is on the way",
    body: ["Your payout was initiated and should arrive in your bank account by Thursday.", "You can follow its status any time from the Payouts page in your dashboard."],
  },
  {
    id: "m3", folder: "inbox", time: "Yesterday",
    from: { name: "Jonas Ortiz", email: "jonas@acme.com" },
    subject: "Launch checklist is ready",
    body: ["Everything is green except the pricing page copy. Elena is finishing it this afternoon.", "I’ll post the final checklist in the channel at 5pm. Shout if you want anything added."],
  },
  {
    id: "m4", folder: "inbox", time: "Yesterday",
    from: { name: "Amara Singh", email: "amara@acme.com" },
    subject: "Postmortem: the slow Tuesday",
    body: ["Draft attached. The short version: a retry storm after a deploy, fixed in two places.", "Comments welcome before we publish it internally."],
    attachments: [{ name: "postmortem-tuesday.pdf", size: "92 KB" }],
  },
  {
    id: "m5", folder: "inbox", time: "Mon",
    from: { name: "Northwind Billing", email: "billing@northwind.co" },
    subject: "Invoice INV-1042 is overdue",
    body: ["This is a friendly reminder that invoice INV-1042 for $4,200.00 was due on September 25.", "You can pay online with the link in your customer portal."],
  },
  {
    id: "m6", folder: "inbox", time: "Mon",
    from: { name: "Elena Fischer", email: "elena@acme.com" },
    subject: "New empty states, take a look",
    body: ["I redrew the empty states for inbox, projects and reports. They’re calmer and give one clear next step.", "Figma link is in the design channel."],
  },
  {
    id: "m7", folder: "sent", time: "Sep 26",
    from: { name: "Jordan Lee", email: "jordan@acme.com" },
    subject: "Re: Hiring plan for next quarter",
    body: ["Thanks for sending this over. I’m on board with two engineers and a designer. Let’s hold the third role until we see the October numbers."],
  },
  {
    id: "m8", folder: "archive", time: "Sep 24",
    from: { name: "Tomás Herrera", email: "tomas@acme.com" },
    subject: "Customer story: Northwind goes paperless",
    body: ["The interview is done and the write-up is approved. It goes live on Thursday."],
  },
]

const folderMeta: { id: Mail1Folder; label: string; icon: typeof Inbox }[] = [
  { id: "inbox", label: "Inbox", icon: Inbox },
  { id: "sent", label: "Sent", icon: Send },
  { id: "archive", label: "Archive", icon: Archive },
]

const tones = ["bg-chart-1/25", "bg-chart-3/25", "bg-chart-5/25", "bg-chart-2/25", "bg-chart-4/25"]
const initials = (name: string) => name.split(" ").map((w) => w[0]).join("").slice(0, 2)
const toneOf = (name: string) => tones[name.split("").reduce((n, c) => n + c.charCodeAt(0), 0) % tones.length]

function Mail1Inner({
  emptyState,
  messages: initial = defaults,
  me = { name: "Jordan Lee", email: "jordan@acme.com" },
  onSend,
  height = "44rem",
  className,
  style,
  ...props
}: Mail1Props) {
  // The message that opens first counts as read.
  const firstOpen = initial.find((m) => m.folder === "inbox")?.id ?? null
  const [mails, setMails] = React.useState(() => initial.map((m) => (m.id === firstOpen ? { ...m, unread: false } : m)))
  const [folder, setFolder] = React.useState<Mail1Folder | "starred">("inbox")
  const [openId, setOpenId] = React.useState<string | null>(firstOpen)
  const [query, setQuery] = React.useState("")
  const [mobileView, setMobileView] = React.useState<"list" | "read">("list")
  const [reply, setReply] = React.useState<string | null>(null)
  const [announce, setAnnounce] = React.useState("")
  const [compose, setCompose] = React.useState(false)
  const [draft, setDraft] = React.useState({ to: "", subject: "", body: "" })
  const listRef = React.useRef<HTMLUListElement>(null)

  const q = query.trim().toLowerCase()
  const visible = mails.filter((m) => (folder === "starred" ? m.starred : m.folder === folder) && (!q || `${m.from.name} ${m.subject} ${m.body.join(" ")}`.toLowerCase().includes(q)))
  const open = mails.find((m) => m.id === openId)
  const shown = open && visible.some((m) => m.id === open.id) ? open : undefined
  const unread = mails.filter((m) => m.folder === "inbox" && m.unread).length

  const update = (id: string, patch: Partial<Mail1Message>) => setMails((all) => all.map((m) => (m.id === id ? { ...m, ...patch } : m)))
  function select(id: string) {
    setOpenId(id)
    update(id, { unread: false })
    setMobileView("read")
    setReply(null)
  }
  function after(removedId: string, text: string) {
    const idx = visible.findIndex((m) => m.id === removedId)
    const next = visible[idx + 1] ?? visible[idx - 1]
    setOpenId(next && next.id !== removedId ? next.id : null)
    setMobileView("list")
    setAnnounce(text)
  }
  function keyNav(e: React.KeyboardEvent<HTMLUListElement>) {
    const keys = ["ArrowDown", "ArrowUp", "j", "k"]
    if (!keys.includes(e.key)) return
    const items = Array.from(listRef.current?.querySelectorAll<HTMLButtonElement>("[data-mail]") ?? [])
    const i = items.indexOf(document.activeElement as HTMLButtonElement)
    if (i === -1) return
    e.preventDefault()
    const next = e.key === "ArrowDown" || e.key === "j" ? items[i + 1] : items[i - 1]
    next?.focus()
  }
  function send(to: string, subject: string, body: string, from: Mail1Folder = "sent") {
    setMails((all) => [{ id: `sent-${all.length + 1}`, folder: from, from: me, subject, body: body.split("\n").filter(Boolean), time: "Just now" }, ...all])
    onSend?.(to, subject, body)
    setAnnounce("Message sent.")
  }

  return (
    <div
      data-slot="mail-1"
      className={cn("bg-background @container flex overflow-hidden rounded-2xl border shadow-[0_30px_80px_-50px_rgb(0_0_0/0.4)]", className)}
      style={{ height, ...style }}
      {...props}
    >
      <p role="status" className="sr-only">{announce}</p>

      {/* Folders */}
      <nav aria-label="Folders" className="bg-muted/30 hidden w-52 shrink-0 flex-col border-e p-3 md:flex">
        <Dialog open={compose} onOpenChange={setCompose}>
          <DialogTrigger className={buttonVariants({ className: "w-full" })}><PenSquare /> Compose</DialogTrigger>
          <DialogContent>
            <form onSubmit={(e) => { e.preventDefault(); if (!draft.to.trim() || !draft.subject.trim()) return; send(draft.to, draft.subject, draft.body); setDraft({ to: "", subject: "", body: "" }); setCompose(false) }}>
              <DialogHeader>
                <DialogTitle>New message</DialogTitle>
                <DialogDescription>It will appear in Sent.</DialogDescription>
              </DialogHeader>
              <div className="my-5 grid gap-3">
                <Input aria-label="To" type="email" placeholder="To" value={draft.to} onChange={(e) => setDraft({ ...draft, to: e.target.value })} required />
                <Input aria-label="Subject" placeholder="Subject" value={draft.subject} onChange={(e) => setDraft({ ...draft, subject: e.target.value })} required />
                <Textarea aria-label="Message" rows={6} placeholder="Write your message…" value={draft.body} onChange={(e) => setDraft({ ...draft, body: e.target.value })} />
              </div>
              <DialogFooter>
                <Button type="button" variant="ghost" onClick={() => setCompose(false)}>Discard</Button>
                <Button type="submit"><Send  className="rtl:-scale-x-100"/> Send</Button>
              </DialogFooter>
            </form>
          </DialogContent>
        </Dialog>
        <ul className="mt-4 space-y-0.5">
          {folderMeta.map((f) => (
            <li key={f.id}>
              <button type="button" aria-current={folder === f.id ? "true" : undefined} onClick={() => { setFolder(f.id); setOpenId(null); setMobileView("list") }} className={cn("focus-visible:ring-ring/50 flex h-9 w-full items-center gap-2.5 rounded-lg px-2.5 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px]", folder === f.id ? "bg-accent text-accent-foreground" : "text-muted-foreground hover:bg-accent/60 hover:text-foreground")}>
                <f.icon className="size-4" aria-hidden="true" />
                <span className="flex-1 text-start">{f.label}</span>
                {f.id === "inbox" && unread > 0 && <span className="bg-foreground text-background rounded-full px-1.5 py-px text-[11px] font-semibold tabular-nums">{unread}<span className="sr-only"> unread</span></span>}
              </button>
            </li>
          ))}
          <li>
            <button type="button" aria-current={folder === "starred" ? "true" : undefined} onClick={() => { setFolder("starred"); setOpenId(null); setMobileView("list") }} className={cn("focus-visible:ring-ring/50 flex h-9 w-full items-center gap-2.5 rounded-lg px-2.5 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px]", folder === "starred" ? "bg-accent text-accent-foreground" : "text-muted-foreground hover:bg-accent/60 hover:text-foreground")}>
              <Star className="size-4" aria-hidden="true" /><span className="flex-1 text-start">Starred</span>
            </button>
          </li>
        </ul>
      </nav>

      {/* Message list */}
      <section aria-label="Messages" className={cn("flex w-full min-w-0 flex-col border-e md:w-80 md:shrink-0 lg:w-96", mobileView === "read" && "hidden md:flex")}>
        <div className="space-y-2 border-b p-3">
          <div className="flex items-center gap-2 md:hidden">
            <Select value={folder} onValueChange={(v) => { setFolder(v as Mail1Folder | "starred"); setOpenId(null) }}>
              <SelectTrigger aria-label="Folder" size="sm" className="flex-1"><SelectValue /></SelectTrigger>
              <SelectContent>
                {folderMeta.map((f) => <SelectItem key={f.id} value={f.id}>{f.label}</SelectItem>)}
                <SelectItem value="starred">Starred</SelectItem>
              </SelectContent>
            </Select>
            <Button size="icon" className="size-8" aria-label="Compose" onClick={() => setCompose(true)}><PenSquare /></Button>
          </div>
          <SearchField label="Search mail" placeholder="Search mail" value={query} onValueChange={setQuery} />
        </div>
        <ul ref={listRef} onKeyDown={keyNav} aria-label={`${folder === "starred" ? "Starred" : folderMeta.find((f) => f.id === folder)?.label} messages`} className="min-h-0 flex-1 divide-y overflow-y-auto">
          {visible.map((m) => (
            <li key={m.id} className="relative">
              <button type="button" data-mail="" aria-current={shown?.id === m.id ? "true" : undefined} onClick={() => select(m.id)} className={cn("focus-visible:ring-ring/50 flex w-full gap-3 p-4 pe-11 text-start outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-inset", shown?.id === m.id ? "bg-accent/70" : "hover:bg-accent/40")}>
                <Avatar size="sm"><AvatarFallback className={cn("text-foreground text-[11px] font-semibold", toneOf(m.from.name))}>{initials(m.from.name)}</AvatarFallback></Avatar>
                <span className="min-w-0 flex-1">
                  <span className="flex items-baseline justify-between gap-2">
                    <span className={cn("truncate text-sm", m.unread ? "font-semibold" : "font-medium")}>{m.unread && <span className="sr-only">Unread, </span>}{m.from.name}</span>
                    <span className="text-muted-foreground shrink-0 text-xs">{m.time}</span>
                  </span>
                  <span className={cn("mt-0.5 block truncate text-sm", m.unread ? "text-foreground font-medium" : "text-muted-foreground")}>{m.subject}</span>
                  <span className="text-muted-foreground mt-0.5 line-clamp-1 block text-xs">{m.body[0]}</span>
                </span>
                {m.unread && <span aria-hidden="true" className="bg-chart-1 mt-1.5 size-2 shrink-0 rounded-full" />}
              </button>
              <button type="button" aria-label={m.starred ? "Remove star" : "Star"} aria-pressed={!!m.starred} onClick={() => update(m.id, { starred: !m.starred })} className="hover:bg-accent focus-visible:ring-ring/50 absolute end-2 bottom-2 flex size-8 items-center justify-center rounded-lg outline-none focus-visible:ring-[3px]">
                <Star className={cn("size-4", m.starred ? "fill-chart-3 text-chart-3" : "text-muted-foreground")} aria-hidden="true" />
              </button>
            </li>
          ))}
          {visible.length === 0 && (
            <li className="text-muted-foreground flex h-full flex-col items-center justify-center px-6 py-16 text-center text-sm">
              <MailOpen className="size-8" aria-hidden="true" />
              <p className="mt-3 font-medium">{q ? "No messages match" : (emptyState?.title ?? "Nothing here")}</p>
              <p className="mt-1 text-xs">{q ? "Try a different search." : (emptyState?.description ?? "Messages you move here will show up.")}</p>
            </li>
          )}
        </ul>
      </section>

      {/* Reading pane */}
      <article aria-label={shown ? shown.subject : "Message"} className={cn("min-w-0 flex-1 flex-col", mobileView === "read" ? "flex" : "hidden md:flex")}>
        {shown ? (
          <>
            <div className="flex items-center gap-1 border-b p-2">
              <Button variant="ghost" size="icon" className="md:hidden" aria-label="Back to messages" onClick={() => setMobileView("list")}><ArrowLeft  className="rtl:rotate-180"/></Button>
              <Button variant="ghost" size="icon" aria-label="Archive" onClick={() => { update(shown.id, { folder: "archive" }); after(shown.id, "Conversation archived.") }}><Archive /></Button>
              <Button variant="ghost" size="icon" aria-label="Delete" onClick={() => { setMails((all) => all.filter((m) => m.id !== shown.id)); after(shown.id, "Conversation deleted.") }}><Trash2 /></Button>
              <Button variant="ghost" size="icon" aria-label="Mark as unread" onClick={() => { update(shown.id, { unread: true }); after(shown.id, "Marked as unread.") }}><MailOpen /></Button>
              <Button variant="ghost" size="sm" className="ms-auto" onClick={() => setReply("")}><Reply  className="rtl:-scale-x-100"/> Reply</Button>
            </div>
            <div className="min-h-0 flex-1 overflow-y-auto p-5 sm:p-8" tabIndex={0}>
              <h2 className="text-xl font-semibold tracking-[-0.02em] text-balance sm:text-2xl">{shown.subject}</h2>
              <div className="mt-5 flex items-center gap-3">
                <Avatar><AvatarFallback className={cn("text-foreground text-xs font-semibold", toneOf(shown.from.name))}>{initials(shown.from.name)}</AvatarFallback></Avatar>
                <div className="min-w-0 text-sm leading-tight">
                  <p className="font-medium">{shown.from.name}</p>
                  <p className="text-muted-foreground truncate">{shown.from.email}</p>
                </div>
                <p className="text-muted-foreground ms-auto text-xs">{shown.time}</p>
              </div>
              <div className="mt-6 space-y-4 text-[15px] leading-7">
                {shown.body.map((p, i) => <p key={i} className="whitespace-pre-line">{p}</p>)}
              </div>
              {shown.attachments && (
                <ul className="mt-6 flex flex-wrap gap-2">
                  {shown.attachments.map((a) => (
                    <li key={a.name} className="bg-muted/50 flex items-center gap-2 rounded-xl border px-3 py-2 text-sm"><Paperclip className="size-4" aria-hidden="true" />{a.name}<span className="text-muted-foreground text-xs">{a.size}</span></li>
                  ))}
                </ul>
              )}
              {reply !== null && (
                <form
                  className="bg-card mt-8 rounded-2xl border p-3"
                  onSubmit={(e) => { e.preventDefault(); if (!reply.trim()) return; send(shown.from.email, `Re: ${shown.subject}`, reply); setReply(null) }}
                >
                  <Textarea aria-label={`Reply to ${shown.from.name}`} autoFocus rows={4} placeholder={`Reply to ${shown.from.name}…`} value={reply} onChange={(e) => setReply(e.target.value)} className="border-0 shadow-none focus-visible:ring-0" />
                  <div className="flex justify-end gap-2 pt-2">
                    <Button type="button" variant="ghost" size="sm" onClick={() => setReply(null)}>Discard</Button>
                    <Button type="submit" size="sm" disabled={!reply.trim()}><Send  className="rtl:-scale-x-100"/> Send</Button>
                  </div>
                </form>
              )}
            </div>
          </>
        ) : (
          <div className="text-muted-foreground flex h-full flex-col items-center justify-center p-8 text-center">
            <MailOpen className="size-10" aria-hidden="true" />
            <p className="mt-4 font-medium">Select a message</p>
            <p className="mt-1 text-sm">Use the arrow keys or J and K to move through your list.</p>
          </div>
        )}
      </article>
    </div>
  )
}


/** The same three panes as the ready view, so nothing jumps when messages arrive. */
function Mail1Skeleton() {
  return (
    <div className="flex h-full" aria-hidden="true">
      <div className="bg-muted/30 hidden w-52 shrink-0 flex-col gap-2 border-e p-3 md:flex">
        <Skeleton className="h-9 w-full rounded-lg" />
        <div className="mt-3 grid gap-1.5">
          {[0, 1, 2, 3].map((i) => (
            <Skeleton key={i} className="h-8 w-full" />
          ))}
        </div>
      </div>
      <div className="flex w-full min-w-0 flex-col border-e md:w-80 md:shrink-0 lg:w-96">
        <div className="border-b p-3">
          <Skeleton className="h-9 w-full rounded-lg" />
        </div>
        <ul className="divide-y">
          {[0, 1, 2, 3, 4, 5].map((i) => (
            <li key={i} className="flex gap-3 p-3.5">
              <Skeleton className="size-8 shrink-0 rounded-full" />
              <div className="grid min-w-0 flex-1 gap-2">
                <div className="flex justify-between gap-6">
                  <Skeleton className="h-3.5 w-1/3" />
                  <Skeleton className="h-3 w-10" />
                </div>
                <Skeleton className="h-3.5 w-4/5" />
                <Skeleton className="h-3 w-full" />
              </div>
            </li>
          ))}
        </ul>
      </div>
      <div className="hidden min-w-0 flex-1 flex-col gap-5 p-8 md:flex">
        <Skeleton className="h-7 w-3/5" />
        <div className="flex items-center gap-3">
          <Skeleton className="size-10 rounded-full" />
          <div className="grid gap-1.5">
            <Skeleton className="h-3.5 w-32" />
            <Skeleton className="h-3 w-44" />
          </div>
        </div>
        <div className="mt-2 grid gap-2.5">
          <Skeleton className="h-3.5 w-full" />
          <Skeleton className="h-3.5 w-full" />
          <Skeleton className="h-3.5 w-11/12" />
          <Skeleton className="h-3.5 w-4/5" />
        </div>
      </div>
    </div>
  )
}

/**
 * The mailbox. With `state="loading"` it shows a skeleton of its own three panes, with `state="error"` an error with retry in the
 * same frame, and with `state="empty"` the real interface over an empty mailbox, so Compose still works.
 */
function Mail1({ state = "ready", onRetry, emptyState, errorState, messages, height = "44rem", className, style, ...props }: Mail1Props) {
  if (state === "loading" || state === "error") {
    return (
      <div
        data-slot="mail-1"
        data-state={state}
        className={cn("bg-background @container overflow-hidden rounded-2xl border shadow-[0_30px_80px_-50px_rgb(0_0_0/0.4)]", className)}
        style={{ height, ...style }}
        {...frameProps(props)}
      >
        <StateView
          state={state}
          bare
          className="h-full"
          loading={<Mail1Skeleton />}
          label="Loading messages"
          error={{ title: "Could not load your mail", description: "The mail service did not answer. Your messages are safe.", ...errorState }}
          onRetry={onRetry}
        >
          {null}
        </StateView>
      </div>
    )
  }
  return <Mail1Inner messages={state === "empty" ? [] : messages} emptyState={emptyState} height={height} className={className} style={style} {...props} />
}

export { Mail1, type Mail1Props, type Mail1Message, type Mail1Folder }
```
