# Inbox Mail

> A three-column mail client with mailboxes, a searchable message list, and a message reader with reply.

A shadcn/ui block from the [@esuiss](https://suiss.app/ui?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/esuiss/inbox-mail).

- Page, with a live preview: https://shadcn.exchange/r/esuiss/inbox-mail
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/esuiss/inbox-mail-light-20afe4dc9272b36f.png
- Registry item: https://suiss.app/ui/r/new-york-v4/inbox-mail.json
- Categories: blocks, inbox
- Listed: 2026-10-20, source last changed 2026-10-20

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/esuiss/inbox-mail.json
```

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

## Dependencies

- Registry items: `@esuiss/avatar`, `@esuiss/button`, `@esuiss/input-group`, `@esuiss/split-view`, `@esuiss/textarea`

## Files

### `components/blocks/inbox-mail.tsx`

```tsx
"use client"

import * as React from "react"

import { IconPlaceholder } from "@/app/(create)/components/icon-placeholder"
import {
  SplitView,
  SplitViewDetail,
  SplitViewItem,
  SplitViewList,
  SplitViewSidebar,
} from "@/components/patterns/split-view"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group"
import { Textarea } from "@/components/ui/textarea"

type Box = "Inbox" | "Sent" | "Archive" | "Trash"

type Mail = {
  id: number
  box: Box
  from: string
  subject: string
  body: string
  time: string
  hue: number
  unread: boolean
  starred: boolean
}

const initialMail: Mail[] = [
  {
    id: 0,
    box: "Inbox",
    from: "suiss",
    subject: "Your order has shipped",
    body: "Order #W1048 is on its way and arrives tomorrow. A signature is required on delivery.",
    time: "9:12 AM",
    hue: 0,
    unread: true,
    starred: false,
  },
  {
    id: 1,
    box: "Inbox",
    from: "Morgan Lee",
    subject: "Tomorrow's meeting",
    body: "Does 10:00 tomorrow work? The Q4 budget and the campaign calendar are on the agenda. I'll share the deck tonight.",
    time: "8:40 AM",
    hue: 30,
    unread: true,
    starred: false,
  },
  {
    id: 2,
    box: "Inbox",
    from: "suiss Cloud",
    subject: "Your storage is almost full",
    body: "90% of your Cloud storage is used. Upgrade your plan to keep your backups running.",
    time: "Yesterday",
    hue: 210,
    unread: true,
    starred: false,
  },
  {
    id: 3,
    box: "Inbox",
    from: "Riley Chen",
    subject: "Coast trip photos",
    body: "I added the photos to the shared album. The ones from the boat tour came out great.",
    time: "Yesterday",
    hue: 150,
    unread: false,
    starred: true,
  },
  {
    id: 4,
    box: "Inbox",
    from: "suiss Store",
    subject: "Just for you: a Trade In offer",
    body: "Bring in your old phone and get up to $650 off a new one.",
    time: "Monday",
    hue: 0,
    unread: false,
    starred: false,
  },
  {
    id: 5,
    box: "Inbox",
    from: "App Store",
    subject: "Your receipt",
    body: "Your Studio Pro subscription renewed. Total: $4.99.",
    time: "Monday",
    hue: 0,
    unread: false,
    starred: false,
  },
  {
    id: 6,
    box: "Sent",
    from: "Riley Chen",
    subject: "Re: Coast trip photos",
    body: "These came out great. Can you send the full-size one from the boat tour?",
    time: "Yesterday",
    hue: 150,
    unread: false,
    starred: false,
  },
]

const mailboxes = [
  {
    value: "Inbox",
    icon: (
      <IconPlaceholder
        lucide="InboxIcon"
        tabler="IconInbox"
        hugeicons="InboxIcon"
        phosphor="TrayIcon"
        remixicon="RiInboxLine"
        className="size-4.5 text-primary"
      />
    ),
  },
  {
    value: "Starred",
    icon: (
      <IconPlaceholder
        lucide="StarIcon"
        tabler="IconStar"
        hugeicons="StarIcon"
        phosphor="StarIcon"
        remixicon="RiStarLine"
        className="size-4.5 text-primary"
      />
    ),
  },
  {
    value: "Sent",
    icon: (
      <IconPlaceholder
        lucide="SendIcon"
        tabler="IconSend"
        hugeicons="SentIcon"
        phosphor="PaperPlaneTiltIcon"
        remixicon="RiSendPlaneLine"
        className="size-4.5 text-primary"
      />
    ),
  },
  {
    value: "Archive",
    icon: (
      <IconPlaceholder
        lucide="ArchiveIcon"
        tabler="IconArchive"
        hugeicons="Archive02Icon"
        phosphor="ArchiveIcon"
        remixicon="RiArchiveLine"
        className="size-4.5 text-primary"
      />
    ),
  },
  {
    value: "Trash",
    icon: (
      <IconPlaceholder
        lucide="TrashIcon"
        tabler="IconTrash"
        hugeicons="Delete02Icon"
        phosphor="TrashIcon"
        remixicon="RiDeleteBinLine"
        className="size-4.5 text-primary"
      />
    ),
  },
]

function SenderAvatar({ mail, className }: { mail: Mail; className?: string }) {
  return (
    <Avatar className={className}>
      <AvatarFallback
        style={
          mail.hue
            ? {
                background: `linear-gradient(160deg, oklch(0.78 0.1 ${mail.hue}), oklch(0.6 0.14 ${mail.hue + 30}))`,
              }
            : undefined
        }
        className="bg-none bg-foreground text-white normal-case dark:bg-none dark:bg-foreground dark:text-background"
      >
        {mail.hue
          ? mail.from
              .split(" ")
              .map((part) => part[0])
              .join("")
          : "s"}
      </AvatarFallback>
    </Avatar>
  )
}

function Reader({
  mail,
  onStar,
  onArchive,
  onDelete,
}: {
  mail: Mail
  onStar: () => void
  onArchive: () => void
  onDelete: () => void
}) {
  const [replying, setReplying] = React.useState(false)
  const [reply, setReply] = React.useState("")

  const actions = [
    {
      label: "Reply",
      icon: (
        <IconPlaceholder
          lucide="ReplyIcon"
          tabler="IconArrowBackUp"
          hugeicons="ArrowTurnBackwardIcon"
          phosphor="ArrowBendUpLeftIcon"
          remixicon="RiReplyLine"
          className="size-4.5"
        />
      ),
      onClick: () => setReplying(true),
    },
    {
      label: mail.starred ? "Unstar" : "Star",
      icon: (
        <IconPlaceholder
          lucide="StarIcon"
          tabler="IconStar"
          hugeicons="StarIcon"
          phosphor="StarIcon"
          remixicon="RiStarLine"
          className="size-4.5"
        />
      ),
      onClick: onStar,
      active: mail.starred,
    },
    {
      label: "Archive",
      icon: (
        <IconPlaceholder
          lucide="ArchiveIcon"
          tabler="IconArchive"
          hugeicons="Archive02Icon"
          phosphor="ArchiveIcon"
          remixicon="RiArchiveLine"
          className="size-4.5"
        />
      ),
      onClick: onArchive,
    },
    {
      label: "Delete",
      icon: (
        <IconPlaceholder
          lucide="TrashIcon"
          tabler="IconTrash"
          hugeicons="Delete02Icon"
          phosphor="TrashIcon"
          remixicon="RiDeleteBinLine"
          className="size-4.5"
        />
      ),
      onClick: onDelete,
    },
  ]

  return (
    <div key={mail.id} className="flex min-h-0 flex-1 flex-col">
      <div className="flex h-12.5 shrink-0 items-center justify-end gap-0.5 border-b border-border max-md:-mx-4 max-md:px-2 md:-mx-8 md:-mt-8 md:px-3">
        {actions.map((action) => (
          <Button
            key={action.label}
            variant="ghost"
            size="icon"
            aria-label={action.label}
            aria-pressed={action.active}
            onClick={action.onClick}
            data-active={action.active ? "" : undefined}
            className="text-muted-foreground data-active:[&_svg]:fill-current data-active:text-[color-mix(in_oklab,var(--color-orange-500),var(--foreground)_45%)] dark:data-active:text-orange-500"
          >
            {action.icon}
          </Button>
        ))}
      </div>
      <div className="flex flex-col gap-4 pt-5.5 transition-[opacity,translate] duration-800 ease-[cubic-bezier(0.32,0.72,0,1)] starting:translate-y-2 starting:opacity-0 motion-reduce:transition-none">
        <div className="flex items-center gap-3">
          <SenderAvatar mail={mail} className="size-10" />
          <div className="flex flex-1 flex-col">
            <span className="text-base font-semibold">{mail.from}</span>
            <span className="text-xs text-muted-foreground">
              To: Jamie Rivera
            </span>
          </div>
          <span className="text-xs text-muted-foreground">{mail.time}</span>
        </div>
        <h3 className="text-2xl font-semibold tracking-tight">
          {mail.subject}
        </h3>
        <p className="text-base leading-relaxed">{mail.body}</p>
      </div>
      {replying && (
        <form
          onSubmit={(event) => {
            event.preventDefault()
            setReplying(false)
            setReply("")
          }}
          className="mt-5.5 flex flex-col gap-2.5 border-t border-border pt-4.5 transition-[opacity,translate] duration-800 starting:translate-y-2 starting:opacity-0 motion-reduce:transition-none"
        >
          <Textarea
            autoFocus
            rows={4}
            value={reply}
            onChange={(event) => setReply(event.target.value)}
            placeholder={`Reply to ${mail.from}`}
            aria-label={`Reply to ${mail.from}`}
          />
          <div className="flex gap-2.5">
            <Button type="submit" size="sm" disabled={!reply}>
              Send
            </Button>
            <Button
              type="button"
              size="sm"
              variant="outline"
              onClick={() => setReplying(false)}
            >
              Cancel
            </Button>
          </div>
        </form>
      )}
    </div>
  )
}

export function InboxMail() {
  const [mail, setMail] = React.useState(initialMail)
  const [mailbox, setMailbox] = React.useState("Inbox")
  const [selected, setSelected] = React.useState<number | null>(0)
  const [query, setQuery] = React.useState("")
  const inMailbox = (item: Mail, box: string) =>
    box === "Starred" ? item.starred && item.box !== "Trash" : item.box === box
  const counts: Record<string, number> = {
    Inbox: mail.filter((item) => item.box === "Inbox" && item.unread).length,
    Starred: mail.filter((item) => inMailbox(item, "Starred")).length,
  }
  const visible = (items: Mail[]) =>
    items.filter(
      (item) =>
        inMailbox(item, mailbox) &&
        (!query ||
          `${item.from} ${item.subject} ${item.body}`
            .toLowerCase()
            .includes(query.toLowerCase()))
    )
  const list = visible(mail)
  const unread = list.filter((item) => item.unread).length
  const current = mail.find((item) => item.id === selected)

  const update = (id: number, patch: Partial<Mail>) =>
    setMail((items) =>
      items.map((item) => (item.id === id ? { ...item, ...patch } : item))
    )

  const move = (id: number, box: Box) => {
    const target = mail.find((item) => item.id === id)
    if (!target || (target.box === box && box !== "Trash")) return
    const rest =
      target.box === "Trash"
        ? mail.filter((item) => item.id !== id)
        : mail.map((item) => (item.id === id ? { ...item, box } : item))
    const index = list.findIndex((item) => item.id === id)
    const remaining = visible(rest)
    setMail(rest)
    if (remaining.some((item) => item.id === id)) return
    const next = list
      .slice(index + 1)
      .concat(list.slice(0, Math.max(index, 0)).reverse())
      .find((item) => remaining.some((other) => other.id === item.id))
    setSelected(next?.id ?? null)
  }

  return (
    <SplitView
      defaultColumn="list"
      className="h-155 rounded-none border-0 max-md:h-auto max-md:min-h-155 max-md:p-4"
    >
      <SplitViewSidebar title="Mailboxes" className="min-h-full md:bg-muted">
        {mailboxes.map((box) => (
          <SplitViewItem
            key={box.value}
            isActive={mailbox === box.value}
            onClick={() => setMailbox(box.value)}
          >
            <span className="flex items-center gap-2.5">
              {box.icon}
              <span className="flex-1">{box.value}</span>
              <span className="text-xs text-muted-foreground tabular-nums">
                {counts[box.value] || ""}
              </span>
            </span>
          </SplitViewItem>
        ))}
      </SplitViewSidebar>
      <SplitViewList title={mailbox} className="gap-2 md:px-2 md:pt-3">
        <div className="flex items-center gap-2 md:px-1">
          <div className="flex flex-1 flex-col max-md:hidden">
            <span className="text-lg font-semibold">{mailbox}</span>
            <span className="text-xs text-muted-foreground">
              {unread ? `${unread} unread` : "All read"}
            </span>
          </div>
          <Button
            variant="ghost"
            size="icon"
            aria-label="New message"
            className="text-muted-foreground max-md:ms-auto max-md:-mt-12"
          >
            <IconPlaceholder
              lucide="SquarePenIcon"
              tabler="IconEdit"
              hugeicons="PencilEdit02Icon"
              phosphor="NotePencilIcon"
              remixicon="RiEditBoxLine"
              className="size-4.5"
            />
          </Button>
        </div>
        <InputGroup className="h-8 shrink-0 md:mx-1">
          <InputGroupAddon>
            <IconPlaceholder
              lucide="SearchIcon"
              tabler="IconSearch"
              hugeicons="Search01Icon"
              phosphor="MagnifyingGlassIcon"
              remixicon="RiSearchLine"
            />
          </InputGroupAddon>
          <InputGroupInput
            type="search"
            value={query}
            onChange={(event) => setQuery(event.target.value)}
            placeholder="Search"
            aria-label="Search mail"
            className="text-sm"
          />
        </InputGroup>
        <div className="flex flex-col gap-0.5">
          {list.length > 0 ? (
            list.map((item) => (
              <SplitViewItem
                key={item.id}
                isActive={item.id === selected}
                onClick={() => {
                  setSelected(item.id)
                  update(item.id, { unread: false })
                }}
                className="group/mail relative py-2.5 ps-6 md:data-active:bg-primary md:data-active:text-primary-foreground"
              >
                {item.unread && (
                  <span className="absolute inset-s-2.5 top-4.25 size-2 rounded-full bg-primary md:group-data-active/mail:bg-primary-foreground">
                    <span className="sr-only">Unread</span>
                  </span>
                )}
                <span className="flex items-center gap-1.5">
                  <span className="flex-1 truncate text-sm font-semibold">
                    {item.from}
                  </span>
                  {item.starred && (
                    <IconPlaceholder
                      lucide="StarIcon"
                      tabler="IconStar"
                      hugeicons="StarIcon"
                      phosphor="StarIcon"
                      remixicon="RiStarLine"
                      aria-label="Starred"
                      className="size-3 text-[color-mix(in_oklab,var(--color-orange-500),var(--foreground)_45%)] md:group-data-active/mail:text-primary-foreground dark:text-orange-500 fill-current"
                    />
                  )}
                  <span className="text-xs text-muted-foreground md:group-data-active/mail:text-primary-foreground">
                    {item.time}
                  </span>
                </span>
                <span className="truncate text-sm">{item.subject}</span>
                <span className="line-clamp-2 text-xs leading-snug text-muted-foreground md:group-data-active/mail:text-primary-foreground">
                  {item.body}
                </span>
              </SplitViewItem>
            ))
          ) : (
            <p className="p-6 text-center text-sm text-muted-foreground">
              No Results
            </p>
          )}
        </div>
      </SplitViewList>
      <SplitViewDetail title={current?.subject} className="[&>h2]:sr-only">
        {current ? (
          <Reader
            key={current.id}
            mail={current}
            onStar={() => update(current.id, { starred: !current.starred })}
            onArchive={() => move(current.id, "Archive")}
            onDelete={() => move(current.id, "Trash")}
          />
        ) : (
          <p className="m-auto text-muted-foreground">No Message Selected</p>
        )}
      </SplitViewDetail>
    </SplitView>
  )
}
```
