# Inbox Mail

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

Inbox Mail is an open-source block from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/blocks/inbox-mail

**React**

**Example**

```tsx title="components/blocks/inbox-mail.tsx"
"use client"

import {
  ArchiveIcon,
  ArrowBendUpLeftIcon,
  MagnifyingGlassIcon,
  NotePencilIcon,
  PaperPlaneTiltIcon,
  StarIcon,
  TrashIcon,
  TrayIcon,
} from "@phosphor-icons/react"
import * as React from "react"

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: (
      <TrayIcon className="size-4.5 text-primary" />
    ),
  },
  {
    value: "Starred",
    icon: (
      <StarIcon className="size-4.5 text-primary" />
    ),
  },
  {
    value: "Sent",
    icon: (
      <PaperPlaneTiltIcon className="size-4.5 text-primary" />
    ),
  },
  {
    value: "Archive",
    icon: (
      <ArchiveIcon className="size-4.5 text-primary" />
    ),
  },
  {
    value: "Trash",
    icon: (
      <TrashIcon 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: (
        <ArrowBendUpLeftIcon className="size-4.5" />
      ),
      onClick: () => setReplying(true),
    },
    {
      label: mail.starred ? "Unstar" : "Star",
      icon: (
        <StarIcon className="size-4.5" />
      ),
      onClick: onStar,
      active: mail.starred,
    },
    {
      label: "Archive",
      icon: (
        <ArchiveIcon className="size-4.5" />
      ),
      onClick: onArchive,
    },
    {
      label: "Delete",
      icon: (
        <TrashIcon 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"
          >
            <NotePencilIcon className="size-4.5" />
          </Button>
        </div>
        <InputGroup className="h-8 shrink-0 md:mx-1">
          <InputGroupAddon>
            <MagnifyingGlassIcon />
          </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 && (
                    <StarIcon
                      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>
  )
}
```

A three-pane mail client: mailboxes on the left, a searchable message list in the middle and the open message with a reply box on the right. On small screens the panes collapse into a single view with a back button. Start from it for any inbox-style screen, such as support tickets or notifications.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/inbox-mail
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add blocks inbox-mail
```

### Manual

1. Install the dependencies:

```bash title="Terminal"
npm install @phosphor-icons/react
```

2. Add the pieces it builds on: [Avatar](https://ui.suiss.app/docs/components/avatar), [Button](https://ui.suiss.app/docs/components/button), [Input Group](https://ui.suiss.app/docs/components/input-group), [Split View](https://ui.suiss.app/docs/patterns/split-view), [Textarea](https://ui.suiss.app/docs/components/textarea).

3. Copy the source into `components/blocks/inbox-mail.tsx`:

```tsx title="components/blocks/inbox-mail.tsx"
"use client"

import {
  ArchiveIcon,
  ArrowBendUpLeftIcon,
  MagnifyingGlassIcon,
  NotePencilIcon,
  PaperPlaneTiltIcon,
  StarIcon,
  TrashIcon,
  TrayIcon,
} from "@phosphor-icons/react"
import * as React from "react"

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: (
      <TrayIcon className="size-4.5 text-primary" />
    ),
  },
  {
    value: "Starred",
    icon: (
      <StarIcon className="size-4.5 text-primary" />
    ),
  },
  {
    value: "Sent",
    icon: (
      <PaperPlaneTiltIcon className="size-4.5 text-primary" />
    ),
  },
  {
    value: "Archive",
    icon: (
      <ArchiveIcon className="size-4.5 text-primary" />
    ),
  },
  {
    value: "Trash",
    icon: (
      <TrashIcon 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: (
        <ArrowBendUpLeftIcon className="size-4.5" />
      ),
      onClick: () => setReplying(true),
    },
    {
      label: mail.starred ? "Unstar" : "Star",
      icon: (
        <StarIcon className="size-4.5" />
      ),
      onClick: onStar,
      active: mail.starred,
    },
    {
      label: "Archive",
      icon: (
        <ArchiveIcon className="size-4.5" />
      ),
      onClick: onArchive,
    },
    {
      label: "Delete",
      icon: (
        <TrashIcon 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"
          >
            <NotePencilIcon className="size-4.5" />
          </Button>
        </div>
        <InputGroup className="h-8 shrink-0 md:mx-1">
          <InputGroupAddon>
            <MagnifyingGlassIcon />
          </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 && (
                    <StarIcon
                      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>
  )
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { InboxMail } from "@/components/blocks/inbox-mail"
```

```tsx
<InboxMail />
```

Replace `initialMail` with your messages, each with a sender, subject, body, time, avatar hue and unread and starred flags, and `mailboxes` with your folders. Only Starred actually filters the list, so load each folder from your mail backend. Archive and Delete both remove the message, the reply form only clears on submit, and New message has no handler.

## Notes

Search runs in the browser over sender, subject and body, so move it server-side for real mailboxes. The reply box receives focus when it opens.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/blocks-inbox-mail--default.light.webp)

```tsx title="uniwind/components/blocks/inbox-mail.tsx"
import { cn } from "cn"
import {
  ArchiveIcon,
  ArrowBendUpLeftIcon,
  MagnifyingGlassIcon,
  NotePencilIcon,
  PaperPlaneTiltIcon,
  type Icon as PhosphorIcon,
  StarIcon,
  TrashIcon,
  TrayIcon,
} from "phosphor-react-native"
import * as React from "react"
import { useWindowDimensions, View } from "react-native"
import { FadeInDown, ReduceMotion } from "react-native-reanimated"
import {
  SwipeAction,
  SwipeActions,
  SwipeActionsActions,
  SwipeActionsContent,
} from "@/components/interactions/swipe-actions"
import {
  SplitView,
  type SplitViewColumn,
  SplitViewDetail,
  SplitViewItem,
  SplitViewList,
  SplitViewSidebar,
} from "@/components/patterns/split-view"
import { AnimatedView } from "@/components/ui/animated-view"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"
import { Textarea } from "@/components/ui/textarea"

type Mail = {
  id: number
  from: string
  subject: string
  body: string
  time: string
  tint: string | null
  unread: boolean
  starred: boolean
}

const INITIAL_MAIL: Mail[] = [
  {
    id: 0,
    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",
    tint: null,
    unread: true,
    starred: false,
  },
  {
    id: 1,
    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",
    tint: "bg-orange-500",
    unread: true,
    starred: false,
  },
  {
    id: 2,
    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",
    tint: "bg-blue-500",
    unread: true,
    starred: false,
  },
  {
    id: 3,
    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",
    tint: "bg-green-500",
    unread: false,
    starred: true,
  },
  {
    id: 4,
    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",
    tint: null,
    unread: false,
    starred: false,
  },
  {
    id: 5,
    from: "App Store",
    subject: "Your receipt",
    body: "Your Studio Pro subscription renewed. Total: $4.99.",
    time: "Monday",
    tint: null,
    unread: false,
    starred: false,
  },
]

const MAILBOXES: { value: string; icon: PhosphorIcon }[] = [
  { value: "Inbox", icon: TrayIcon },
  { value: "Starred", icon: StarIcon },
  { value: "Sent", icon: PaperPlaneTiltIcon },
  { value: "Archive", icon: ArchiveIcon },
  { value: "Trash", icon: TrashIcon },
]

const ENTERING = FadeInDown.duration(800).reduceMotion(ReduceMotion.System)

function SenderAvatar({ mail }: { mail: Mail }) {
  return (
    <Avatar size="lg">
      <AvatarFallback
        className={mail.tint ?? "bg-foreground dark:bg-foreground"}
        accessibilityLabel={mail.from}
      >
        <Text className={mail.tint ? undefined : "text-background normal-case"}>
          {mail.tint
            ? mail.from
                .split(" ")
                .map((part) => part[0])
                .join("")
            : "s"}
        </Text>
      </AvatarFallback>
    </Avatar>
  )
}

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

  const actions: {
    label: string
    icon: PhosphorIcon
    onPress: () => void
    active?: boolean
  }[] = [
    {
      label: "Reply",
      icon: ArrowBendUpLeftIcon,
      onPress: () => setReplying(true),
    },
    {
      label: mail.starred ? "Unstar" : "Star",
      icon: StarIcon,
      onPress: onStar,
      active: mail.starred,
    },
    { label: "Archive", icon: ArchiveIcon, onPress: onArchive },
    { label: "Delete", icon: TrashIcon, onPress: onDelete },
  ]

  return (
    <View className="flex-1">
      <View className="h-12.5 flex-row items-center justify-end gap-0.5 border-b border-border">
        {actions.map((action) => (
          <Button
            key={action.label}
            variant="ghost"
            size="icon"
            accessibilityLabel={action.label}
            accessibilityState={
              action.active === undefined
                ? undefined
                : { selected: action.active }
            }
            onPress={action.onPress}
          >
            <Icon
              as={action.icon}
              weight={action.active ? "fill" : "regular"}
              className={cn(
                "size-5",
                action.active ? "text-orange-500" : "text-muted-foreground"
              )}
            />
          </Button>
        ))}
      </View>
      <AnimatedView key={mail.id} entering={ENTERING} className="gap-4 pt-5.5">
        <View className="flex-row items-center gap-3">
          <SenderAvatar mail={mail} />
          <View className="flex-1">
            <Text className="text-base font-semibold">{mail.from}</Text>
            <Text className="text-xs text-muted-foreground">
              To: Jamie Rivera
            </Text>
          </View>
          <Text className="text-xs text-muted-foreground">{mail.time}</Text>
        </View>
        <Text role="heading" className="text-2xl font-semibold">
          {mail.subject}
        </Text>
        <Text className="text-base">{mail.body}</Text>
      </AnimatedView>
      {replying ? (
        <AnimatedView
          entering={ENTERING}
          className="mt-5.5 gap-2.5 border-t border-border pt-4.5"
        >
          <Textarea
            autoFocus
            numberOfLines={4}
            value={reply}
            onChangeText={setReply}
            placeholder={`Reply to ${mail.from}`}
            accessibilityLabel={`Reply to ${mail.from}`}
            className="min-h-24"
          />
          <View className="flex-row gap-2.5">
            <Button
              size="sm"
              disabled={!reply}
              onPress={() => {
                onReply(reply)
                setReplying(false)
                setReply("")
              }}
            >
              <Text>Send</Text>
            </Button>
            <Button
              size="sm"
              variant="outline"
              onPress={() => setReplying(false)}
            >
              <Text>Cancel</Text>
            </Button>
          </View>
        </AnimatedView>
      ) : null}
    </View>
  )
}

function MailRow({
  mail,
  index,
  active,
  onPress,
  onArchive,
  onDelete,
}: {
  mail: Mail
  index: number
  active: boolean
  onPress: () => void
  onArchive: () => void
  onDelete: () => void
}) {
  const muted = active ? "text-primary-foreground" : "text-muted-foreground"
  return (
    <AnimatedView
      entering={FadeInDown.delay(index * 40)
        .duration(700)
        .reduceMotion(ReduceMotion.System)}
    >
      <SwipeActions>
        <SwipeActionsContent className="bg-transparent">
          <SplitViewItem
            isActive={active}
            onPress={onPress}
            accessibilityHint={
              [mail.unread && "Unread", mail.starred && "Starred"]
                .filter(Boolean)
                .join(", ") || undefined
            }
            className={cn("relative py-2.5 ps-6", active && "bg-primary")}
          >
            {mail.unread ? (
              <View
                className={cn(
                  "absolute -start-3.5 top-1.5 size-2 rounded-full",
                  active ? "bg-primary-foreground" : "bg-primary"
                )}
              />
            ) : null}
            <View className="flex-row items-center gap-1.5">
              <Text
                numberOfLines={1}
                className={cn(
                  "flex-1 text-sm font-semibold",
                  active && "text-primary-foreground"
                )}
              >
                {mail.from}
              </Text>
              {mail.starred ? (
                <Icon
                  as={StarIcon}
                  weight="fill"
                  className={cn(
                    "size-3",
                    active ? "text-primary-foreground" : "text-orange-500"
                  )}
                />
              ) : null}
              <Text className={cn("text-xs", muted)}>{mail.time}</Text>
            </View>
            <Text
              numberOfLines={1}
              className={cn("text-sm", active && "text-primary-foreground")}
            >
              {mail.subject}
            </Text>
            <Text numberOfLines={2} className={cn("text-xs", muted)}>
              {mail.body}
            </Text>
          </SplitViewItem>
        </SwipeActionsContent>
        <SwipeActionsActions side="trailing">
          <SwipeAction variant="archive" onPress={onArchive}>
            <Icon as={ArchiveIcon} />
            <Text>Archive</Text>
          </SwipeAction>
          <SwipeAction variant="destructive" fullSwipe onPress={onDelete}>
            <Icon as={TrashIcon} />
            <Text>Delete</Text>
          </SwipeAction>
        </SwipeActionsActions>
      </SwipeActions>
    </AnimatedView>
  )
}

function InboxMail({
  onMailboxChange,
  onNewMessage,
  onReply,
  onStarChange,
  onArchive,
  onDelete,
}: {
  onMailboxChange?: (mailbox: string) => void
  onNewMessage?: () => void
  onReply?: (id: number, text: string) => void
  onStarChange?: (id: number, starred: boolean) => void
  onArchive?: (id: number) => void
  onDelete?: (id: number) => void
}) {
  const { width } = useWindowDimensions()
  const wide = width >= 768
  const [mail, setMail] = React.useState(INITIAL_MAIL)
  const [mailbox, setMailbox] = React.useState("Inbox")
  const [selected, setSelected] = React.useState<number | null>(0)
  const [query, setQuery] = React.useState("")
  const [column, setColumn] = React.useState<SplitViewColumn>("list")
  const unread = mail.filter((item) => item.unread).length
  const starred = mail.filter((item) => item.starred).length
  const counts: Record<string, number> = { Inbox: unread, Starred: starred }
  const list = mail.filter(
    (item) =>
      (mailbox !== "Starred" || item.starred) &&
      (!query ||
        `${item.from} ${item.subject} ${item.body}`
          .toLowerCase()
          .includes(query.toLowerCase()))
  )
  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 remove = (id: number) => {
    const rest = mail.filter((item) => item.id !== id)
    setMail(rest)
    if (selected === id) {
      setSelected(rest[0]?.id ?? null)
      setColumn("list")
    }
  }

  return (
    <Screen edges={["top", "right", "left"]}>
      <SplitView
        column={column}
        onColumnChange={setColumn}
        className={wide ? "m-4" : undefined}
      >
        <SplitViewSidebar
          title="Mailboxes"
          className={wide ? "min-h-full bg-muted" : "px-4 pb-8"}
        >
          {MAILBOXES.map((box) => (
            <SplitViewItem
              key={box.value}
              isActive={mailbox === box.value}
              onPress={() => {
                setMailbox(box.value)
                onMailboxChange?.(box.value)
              }}
              className={
                wide && mailbox === box.value ? "bg-secondary" : undefined
              }
            >
              <View className="flex-row items-center gap-2.5">
                <Icon as={box.icon} className="size-5 text-primary" />
                <Text className="flex-1">{box.value}</Text>
                <Text className="text-xs text-muted-foreground tabular-nums">
                  {counts[box.value] ? String(counts[box.value]) : ""}
                </Text>
              </View>
            </SplitViewItem>
          ))}
        </SplitViewSidebar>
        <SplitViewList
          title={mailbox}
          className={wide ? "gap-2 px-2 pt-3" : "gap-2 px-4 pb-8"}
        >
          <View
            className={cn(
              "flex-row items-center gap-2",
              wide ? "px-1" : "absolute end-2 top-0"
            )}
          >
            {wide ? (
              <View className="flex-1">
                <Text className="text-lg font-semibold">{mailbox}</Text>
                <Text className="text-xs text-muted-foreground">
                  {unread ? `${unread} unread` : "All read"}
                </Text>
              </View>
            ) : null}
            <Button
              variant="ghost"
              size="icon"
              accessibilityLabel="New message"
              onPress={onNewMessage}
            >
              <Icon as={NotePencilIcon} className="size-5 text-primary" />
            </Button>
          </View>
          <InputGroup className={cn("h-9 rounded-[0.625rem]", wide && "mx-1")}>
            <InputGroupAddon>
              <Icon
                as={MagnifyingGlassIcon}
                weight="bold"
                className="size-4 text-muted-foreground"
              />
            </InputGroupAddon>
            <InputGroupInput
              value={query}
              onChangeText={setQuery}
              placeholder="Search"
              accessibilityLabel="Search mail"
              returnKeyType="search"
              autoCorrect={false}
              clearButtonMode="while-editing"
            />
          </InputGroup>
          <View className="gap-0.5">
            {list.length > 0 ? (
              list.map((item, index) => (
                <MailRow
                  key={item.id}
                  mail={item}
                  index={index}
                  active={wide && item.id === selected}
                  onPress={() => {
                    setSelected(item.id)
                    update(item.id, { unread: false })
                  }}
                  onArchive={() => {
                    onArchive?.(item.id)
                    remove(item.id)
                  }}
                  onDelete={() => {
                    onDelete?.(item.id)
                    remove(item.id)
                  }}
                />
              ))
            ) : (
              <Text className="p-6 text-center text-sm text-muted-foreground">
                No Results
              </Text>
            )}
          </View>
        </SplitViewList>
        <SplitViewDetail className={wide ? undefined : "grow px-4 pb-8"}>
          {current ? (
            <Reader
              mail={current}
              onReply={(text) => onReply?.(current.id, text)}
              onStar={() => {
                onStarChange?.(current.id, !current.starred)
                update(current.id, { starred: !current.starred })
              }}
              onArchive={() => {
                onArchive?.(current.id)
                remove(current.id)
              }}
              onDelete={() => {
                onDelete?.(current.id)
                remove(current.id)
              }}
            />
          ) : (
            <View className="flex-1 items-center justify-center py-20">
              <Text className="text-muted-foreground">No Message Selected</Text>
            </View>
          )}
        </SplitViewDetail>
      </SplitView>
    </Screen>
  )
}

export { InboxMail }
```

```swift title="swift/Blocks/InboxMail.swift"
import PhosphorSwift
import SwiftUI

public struct InboxMail: View {
  struct Mail: Identifiable, Equatable {
    let id: Int
    let from: String
    let subject: String
    let body: String
    let time: String
    let tint: Color?
    var unread: Bool
    var starred: Bool
  }

  struct Mailbox {
    let value: String
    let icon: Image
  }

  static var initialMail: [Mail] {
    [
      Mail(id: 0, 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", tint: nil, unread: true, starred: false),
      Mail(id: 1, 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", tint: Palette.orange500, unread: true, starred: false),
      Mail(id: 2, 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", tint: Palette.blue500, unread: true, starred: false),
      Mail(id: 3, 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", tint: Palette.green500, unread: false, starred: true),
      Mail(id: 4, 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", tint: nil, unread: false, starred: false),
      Mail(id: 5, from: "App Store", subject: "Your receipt", body: "Your Studio Pro subscription renewed. Total: $4.99.", time: "Monday", tint: nil, unread: false, starred: false),
    ]
  }

  static var mailboxes: [Mailbox] {
    [
      Mailbox(value: "Inbox", icon: Ph.tray.regular),
      Mailbox(value: "Starred", icon: Ph.star.regular),
      Mailbox(value: "Sent", icon: Ph.paperPlaneTilt.regular),
      Mailbox(value: "Archive", icon: Ph.archive.regular),
      Mailbox(value: "Trash", icon: Ph.trash.regular),
    ]
  }

  private var onMailboxChange: ((String) -> Void)?
  private var onArchive: ((Int) -> Void)?
  private var onDelete: ((Int) -> Void)?
  private var onReply: ((Int, String) -> Void)?
  private var onStarChange: ((Int, Bool) -> Void)?
  private var onNewMessage: (() -> Void)?
  private let replying: Bool
  private let swiped: Int?
  @State private var mail: [Mail]
  @State private var mailbox: String
  @State private var selected: Int?
  @State private var query: String
  @State private var column: SplitViewColumn
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(
    onMailboxChange: ((_ mailbox: String) -> Void)? = nil,
    onNewMessage: (() -> Void)? = nil,
    onReply: ((_ id: Int, _ text: String) -> Void)? = nil,
    onStarChange: ((_ id: Int, _ starred: Bool) -> Void)? = nil,
    onArchive: ((_ id: Int) -> Void)? = nil,
    onDelete: ((_ id: Int) -> Void)? = nil
  ) {
    self.init(mailbox: "Inbox")
    self.onMailboxChange = onMailboxChange
    self.onArchive = onArchive
    self.onDelete = onDelete
    self.onReply = onReply
    self.onStarChange = onStarChange
    self.onNewMessage = onNewMessage
  }

  init(
    mailbox: String = "Inbox",
    selected: Int? = 0,
    query: String = "",
    column: SplitViewColumn = .list,
    replying: Bool = false,
    swiped: Int? = nil
  ) {
    _mail = State(initialValue: Self.initialMail)
    _mailbox = State(initialValue: mailbox)
    _selected = State(initialValue: selected)
    _query = State(initialValue: query)
    _column = State(initialValue: column)
    self.replying = replying
    self.swiped = swiped
  }

  private var animation: Animation? {
    reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.4)
  }

  static func matches(_ mail: Mail, mailbox: String, query: String) -> Bool {
    guard mailbox != "Starred" || mail.starred else { return false }
    guard !query.isEmpty else { return true }
    return "\(mail.from) \(mail.subject) \(mail.body)".lowercased().contains(query.lowercased())
  }

  public var body: some View {
    let list = mail.filter { Self.matches($0, mailbox: mailbox, query: query) }
    let current = mail.first { $0.id == selected }
    let unread = mail.filter(\.unread).count
    let counts = ["Inbox": unread, "Starred": mail.filter(\.starred).count]
    Screen {
      SplitView(column: $column) {
        SplitViewSidebar("Mailboxes") {
          ForEach(Self.mailboxes, id: \.value) { box in
            SplitViewItem(isActive: mailbox == box.value, action: {
              if mailbox != box.value { onMailboxChange?(box.value) }
              mailbox = box.value
            }) {
              HStack(spacing: 10) {
                box.icon
                  .frame(width: 20, height: 20)
                  .foregroundStyle(EsuissColor.primary)
                Text(LocalizedStringKey(box.value))
                  .frame(maxWidth: .infinity, alignment: .leading)
                if let count = counts[box.value], count > 0 {
                  Text(verbatim: "\(count)")
                    .scaledFont(.xs)
                    .monospacedDigit()
                    .foregroundStyle(EsuissColor.mutedForeground)
                }
              }
            }
          }
        }
        SplitViewList(verbatim: mailbox) {
          VStack(alignment: .leading, spacing: 8) {
            InboxMailHeading(mailbox: mailbox, unread: unread)
            HStack(spacing: 8) {
              InputGroup {
                InputGroupAddon {
                  Ph.magnifyingGlass.regular.frame(width: 16, height: 16)
                }
                InputGroupInput("Search", text: $query)
                  .submitLabel(.search)
                  .accessibilityLabel(Text("Search mail"))
              }
              Button {
                onNewMessage?()
              } label: {
                Ph.notePencil.regular
                  .frame(width: 22, height: 22)
                  .foregroundStyle(EsuissColor.primary)
              }
              .buttonStyle(.variant(.ghost, size: .icon))
              .accessibilityLabel(Text("New message"))
            }
            .padding(.bottom, 4)
            VStack(spacing: 0) {
              if list.isEmpty {
                Text("No Results")
                  .scaledFont(.sm)
                  .foregroundStyle(EsuissColor.mutedForeground)
                  .frame(maxWidth: .infinity)
                  .padding(24)
              }
              ForEach(list) { item in
                SwipeActions(revealed: swiped == item.id ? -144 : 0) {
                  SplitViewItem(isActive: item.id == selected, action: {
                    selected = item.id
                    update(item.id) { $0.unread = false }
                  }) {
                    InboxMailRow(mail: item)
                  }
                } leading: {
                  EmptyView()
                } trailing: {
                  SwipeAction("Archive", icon: Ph.archive.fill, variant: .archive) {
                    onArchive?(item.id)
                    remove(item.id)
                  }
                  SwipeAction("Delete", icon: Ph.trash.fill, variant: .destructive, fullSwipe: true) {
                    onDelete?(item.id)
                    remove(item.id)
                  }
                }
                .transition(.opacity.combined(with: .offset(y: 6)))
              }
            }
          }
        }
        SplitViewDetail {
          if let current {
            InboxMailReader(
              mail: current,
              replying: replying,
              onStar: {
                onStarChange?(current.id, !current.starred)
                update(current.id) { $0.starred.toggle() }
              },
              onArchive: {
                onArchive?(current.id)
                remove(current.id)
              },
              onDelete: {
                onDelete?(current.id)
                remove(current.id)
              },
              onReply: { text in onReply?(current.id, text) }
            )
            .id(current.id)
          } else {
            Text("No Message Selected")
              .scaledFont(.base)
              .foregroundStyle(EsuissColor.mutedForeground)
              .frame(maxWidth: .infinity)
              .padding(.top, 120)
          }
        }
      }
    }
  }

  private func update(_ id: Int, _ change: (inout Mail) -> Void) {
    guard let index = mail.firstIndex(where: { $0.id == id }) else { return }
    change(&mail[index])
  }

  private func remove(_ id: Int) {
    withAnimation(animation) {
      mail.removeAll { $0.id == id }
      selected = mail.first?.id
    }
  }
}

private struct InboxMailHeading: View {
  let mailbox: String
  let unread: Int

  @Environment(\.splitView) private var splitView

  var body: some View {
    VStack(alignment: .leading, spacing: 0) {
      if splitView?.wide ?? false {
        Text(LocalizedStringKey(mailbox))
          .scaledFont(.lg, weight: .semibold)
          .accessibilityAddTraits(.isHeader)
      }
      Text(unread > 0 ? "\(unread) unread" : "All read")
        .scaledFont(.xs)
        .foregroundStyle(EsuissColor.mutedForeground)
    }
    .foregroundStyle(EsuissColor.foreground)
  }
}

private struct InboxMailAvatar: View {
  let mail: InboxMail.Mail
  let size: CGFloat

  var body: some View {
    let initials = mail.from.split(separator: " ").compactMap(\.first).map(String.init).joined()
    Text(verbatim: mail.tint == nil ? "s" : initials)
      .font(.system(size: size * 0.4, weight: .semibold))
      .foregroundStyle(mail.tint == nil ? EsuissColor.background : EsuissColor.primaryForeground)
      .frame(width: size, height: size)
      .background(mail.tint ?? EsuissColor.foreground, in: Circle())
      .accessibilityHidden(true)
  }
}

private struct InboxMailRow: View {
  let mail: InboxMail.Mail

  var body: some View {
    HStack(alignment: .top, spacing: 8) {
      Circle()
        .fill(mail.unread ? EsuissColor.primary : .clear)
        .frame(width: 8, height: 8)
        .padding(.top, 6)
        .accessibilityHidden(true)
      VStack(alignment: .leading, spacing: 2) {
        HStack(spacing: 6) {
          Text(verbatim: mail.from)
            .scaledFont(.sm, weight: .semibold)
            .lineLimit(1)
            .frame(maxWidth: .infinity, alignment: .leading)
          if mail.starred {
            Ph.star.fill
              .frame(width: 12, height: 12)
              .foregroundStyle(Palette.orange500)
              .accessibilityLabel(Text("Starred"))
          }
          Text(LocalizedStringKey(mail.time))
            .scaledFont(.xs)
            .foregroundStyle(EsuissColor.mutedForeground)
        }
        Text(verbatim: mail.subject)
          .scaledFont(.sm)
          .lineLimit(1)
        Text(verbatim: mail.body)
          .scaledFont(.xs)
          .foregroundStyle(EsuissColor.mutedForeground)
          .lineLimit(2)
          .multilineTextAlignment(.leading)
      }
    }
    .accessibilityElement(children: .combine)
    .accessibilityValue(mail.unread ? Text("Unread") : Text(verbatim: ""))
  }
}

private struct InboxMailReader: View {
  let mail: InboxMail.Mail
  let onStar: () -> Void
  let onArchive: () -> Void
  let onDelete: () -> Void
  let onReply: (String) -> Void

  @State private var replying: Bool
  @State private var reply = ""
  @FocusState private var focused: Bool
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  init(
    mail: InboxMail.Mail,
    replying: Bool,
    onStar: @escaping () -> Void,
    onArchive: @escaping () -> Void,
    onDelete: @escaping () -> Void,
    onReply: @escaping (String) -> Void
  ) {
    self.mail = mail
    self.onStar = onStar
    self.onArchive = onArchive
    self.onDelete = onDelete
    self.onReply = onReply
    _replying = State(initialValue: replying)
  }

  var body: some View {
    VStack(alignment: .leading, spacing: 0) {
      HStack(spacing: 2) {
        Spacer(minLength: 0)
        action("Reply", icon: Ph.arrowBendUpLeft.regular) {
          replying = true
          focused = true
        }
        action(mail.starred ? "Unstar" : "Star", icon: mail.starred ? Ph.star.fill : Ph.star.regular, active: mail.starred, perform: onStar)
        action("Archive", icon: Ph.archive.regular, perform: onArchive)
        action("Delete", icon: Ph.trash.regular, perform: onDelete)
      }
      .frame(height: 50)
      .overlay(alignment: .bottom) {
        EsuissColor.border.frame(height: 1).padding(.horizontal, -16)
      }
      VStack(alignment: .leading, spacing: 16) {
        HStack(spacing: 12) {
          InboxMailAvatar(mail: mail, size: 40)
          VStack(alignment: .leading, spacing: 0) {
            Text(verbatim: mail.from)
              .scaledFont(.base, weight: .semibold)
            Text("To: Jamie Rivera")
              .scaledFont(.xs)
              .foregroundStyle(EsuissColor.mutedForeground)
          }
          .frame(maxWidth: .infinity, alignment: .leading)
          Text(LocalizedStringKey(mail.time))
            .scaledFont(.xs)
            .foregroundStyle(EsuissColor.mutedForeground)
        }
        .accessibilityElement(children: .combine)
        Text(verbatim: mail.subject)
          .scaledTextStyle(.xl2, weight: .semibold)
          .fixedSize(horizontal: false, vertical: true)
          .accessibilityAddTraits(.isHeader)
        Text(verbatim: mail.body)
          .scaledTextStyle(.base)
          .lineSpacing(4)
          .fixedSize(horizontal: false, vertical: true)
      }
      .padding(.top, 22)
      if replying {
        VStack(alignment: .leading, spacing: 10) {
          TextField("Reply to \(mail.from)", text: $reply, axis: .vertical)
            .textFieldStyle(.textarea)
            .lineLimit(4...8)
            .focused($focused)
            .accessibilityLabel(Text("Reply to \(mail.from)"))
          HStack(spacing: 10) {
            Button("Send") {
              onReply(reply)
              reply = ""
              replying = false
            }
            .buttonStyle(.variant(.default, size: .sm))
            .disabled(reply.isEmpty)
            Button("Cancel") {
              replying = false
            }
            .buttonStyle(.variant(.outline, size: .sm))
          }
        }
        .padding(.top, 18)
        .overlay(alignment: .top) {
          EsuissColor.border.frame(height: 1)
        }
        .padding(.top, 22)
        .transition(.opacity.combined(with: .offset(y: 8)))
      }
    }
    .foregroundStyle(EsuissColor.foreground)
    .animation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.5), value: replying)
  }

  private func action(_ title: LocalizedStringKey, icon: Image, active: Bool = false, perform: @escaping () -> Void) -> some View {
    Button(action: perform) {
      icon
        .frame(width: 20, height: 20)
        .foregroundStyle(active ? Palette.orange500 : EsuissColor.mutedForeground)
    }
    .buttonStyle(.variant(.ghost, size: .icon))
    .accessibilityLabel(Text(title))
    .accessibilityAddTraits(active ? .isSelected : [])
  }
}
```

```kt title="kotlin/blocks/InboxMail.kt"
package com.esuiss.ui.blocks

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Bold
import com.adamglin.phosphoricons.Fill
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.bold.Archive as BoldArchive
import com.adamglin.phosphoricons.bold.Trash as BoldTrash
import com.adamglin.phosphoricons.fill.Star as FillStar
import com.adamglin.phosphoricons.regular.Archive
import com.adamglin.phosphoricons.regular.ArrowBendUpLeft
import com.adamglin.phosphoricons.regular.MagnifyingGlass
import com.adamglin.phosphoricons.regular.NotePencil
import com.adamglin.phosphoricons.regular.PaperPlaneTilt
import com.adamglin.phosphoricons.regular.Star
import com.adamglin.phosphoricons.regular.Trash
import com.adamglin.phosphoricons.regular.Tray
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.InputGroup
import com.esuiss.ui.components.InputGroupAddon
import com.esuiss.ui.components.InputGroupInput
import com.esuiss.ui.components.Screen
import com.esuiss.ui.components.Textarea
import com.esuiss.ui.interactions.SwipeAction
import com.esuiss.ui.interactions.SwipeActionVariant
import com.esuiss.ui.interactions.SwipeActions
import com.esuiss.ui.patterns.SplitView
import com.esuiss.ui.patterns.SplitViewColumn
import com.esuiss.ui.patterns.SplitViewItem
import com.esuiss.ui.theme.Colors
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Typography

private val MailRevealEasing = CubicBezierEasing(0.32f, 0.72f, 0f, 1f)

private class MailMessage(
    val id: Int,
    val from: String,
    val subject: String,
    val body: String,
    val time: String,
    val hue: Int,
    unread: Boolean,
    starred: Boolean,
) {
    var unread by mutableStateOf(unread)
    var starred by mutableStateOf(starred)
}

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

private class Mailbox(val value: String, val icon: ImageVector)

private val Mailboxes = listOf(
    Mailbox("Inbox", PhosphorIcons.Regular.Tray),
    Mailbox("Starred", PhosphorIcons.Regular.Star),
    Mailbox("Sent", PhosphorIcons.Regular.PaperPlaneTilt),
    Mailbox("Archive", PhosphorIcons.Regular.Archive),
    Mailbox("Trash", PhosphorIcons.Regular.Trash),
)

private fun senderTint(colors: Colors, hue: Int): Color = when (hue) {
    30 -> Palette.orange500
    150 -> Palette.green500
    210 -> Palette.blue500
    else -> colors.foreground
}

private fun starTint(colors: Colors): Color = if (colors.isDark) Palette.orange500 else lerp(Palette.orange500, colors.foreground, 0.45f)

@Composable
fun InboxMail(
    modifier: Modifier = Modifier,
    onArchive: (id: Int) -> Unit = {},
    onDelete: (id: Int) -> Unit = {},
    onReply: (id: Int, text: String) -> Unit = { _, _ -> },
    onStarChange: (id: Int, starred: Boolean) -> Unit = { _, _ -> },
    onMailboxChange: (mailbox: String) -> Unit = {},
    onNewMessage: () -> Unit = {},
) {
    val colors = EsuissTheme.colors
    val mail = remember { mutableStateListOf<MailMessage>().apply { addAll(mailMessages()) } }
    var mailbox by rememberSaveable { mutableStateOf("Inbox") }
    var selected by rememberSaveable { mutableStateOf<Int?>(0) }
    var query by rememberSaveable { mutableStateOf("") }
    var column by rememberSaveable { mutableStateOf(SplitViewColumn.List) }
    val unread = mail.count { it.unread }
    val starred = mail.count { it.starred }
    val counts = mapOf("Inbox" to unread, "Starred" to starred)
    val list = mail.filter {
        (mailbox != "Starred" || it.starred) &&
            (query.isBlank() || "${it.from} ${it.subject} ${it.body}".contains(query.trim(), ignoreCase = true))
    }
    val current = mail.firstOrNull { it.id == selected }

    fun remove(item: MailMessage) {
        mail.remove(item)
        selected = mail.firstOrNull()?.id
        column = SplitViewColumn.List
    }

    fun archive(item: MailMessage) {
        onArchive(item.id)
        remove(item)
    }

    fun delete(item: MailMessage) {
        onDelete(item.id)
        remove(item)
    }

    fun star(item: MailMessage) {
        onStarChange(item.id, !item.starred)
        item.starred = !item.starred
    }

    Screen(modifier) {
        SplitView(column = column, onColumnChange = { column = it }) {
            SplitViewSidebar(title = "Mailboxes") {
                Mailboxes.forEach { box ->
                    SplitViewItem(
                        isActive = mailbox == box.value,
                        onClick = {
                            mailbox = box.value
                            onMailboxChange(box.value)
                        },
                    ) {
                        Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
                            Icon(box.icon, contentDescription = null, size = 18.dp, tint = colors.primary)
                            BasicText(box.value, modifier = Modifier.weight(1f), style = Typography.base.copy(color = colors.foreground))
                            val count = counts[box.value] ?: 0
                            if (count > 0) {
                                BasicText("$count", style = Typography.xs.copy(color = colors.mutedForeground, fontFeatureSettings = "tnum"))
                            }
                        }
                    }
                }
            }
            SplitViewList(title = mailbox) {
                Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
                    Row(verticalAlignment = Alignment.CenterVertically) {
                        BasicText(
                            if (unread > 0) "$unread unread" else "All read",
                            modifier = Modifier.weight(1f),
                            style = Typography.xs.copy(color = colors.mutedForeground),
                        )
                        Button(onClick = onNewMessage, variant = ButtonVariant.Ghost, size = ButtonSize.Icon) {
                            Icon(PhosphorIcons.Regular.NotePencil, contentDescription = "New message", size = 20.dp, tint = colors.mutedForeground)
                        }
                    }
                    InputGroup(Modifier.semantics { contentDescription = "Search mail" }) {
                        InputGroupAddon {
                            Icon(PhosphorIcons.Regular.MagnifyingGlass, contentDescription = null, size = 16.dp, tint = colors.mutedForeground)
                        }
                        InputGroupInput(value = query, onValueChange = { query = it }, placeholder = "Search")
                    }
                    Column(Modifier.padding(top = 4.dp)) {
                        if (list.isEmpty()) {
                            BasicText(
                                "No Results",
                                modifier = Modifier
                                    .fillMaxWidth()
                                    .padding(24.dp),
                                style = Typography.sm.copy(color = colors.mutedForeground, textAlign = TextAlign.Center),
                            )
                        }
                        list.forEach { item ->
                            key(item.id) {
                                SwipeActions(
                                    leading = listOf(
                                        SwipeAction(
                                            label = if (item.starred) "Unstar" else "Star",
                                            onClick = { star(item) },
                                            icon = PhosphorIcons.Fill.FillStar,
                                            variant = SwipeActionVariant.Default,
                                        ),
                                    ),
                                    trailing = listOf(
                                        SwipeAction(
                                            label = "Archive",
                                            onClick = { archive(item) },
                                            icon = PhosphorIcons.Bold.BoldArchive,
                                            variant = SwipeActionVariant.Archive,
                                        ),
                                        SwipeAction(
                                            label = "Delete",
                                            onClick = { delete(item) },
                                            icon = PhosphorIcons.Bold.BoldTrash,
                                            variant = SwipeActionVariant.Destructive,
                                            fullSwipe = true,
                                        ),
                                    ),
                                ) {
                                    MailRow(item, active = item.id == selected) {
                                        selected = item.id
                                        item.unread = false
                                    }
                                }
                            }
                        }
                    }
                }
            }
            SplitViewDetail {
                if (current != null) {
                    key(current.id) {
                        MailReader(
                            mail = current,
                            onReply = { onReply(current.id, it) },
                            onStar = { star(current) },
                            onArchive = { archive(current) },
                            onDelete = { delete(current) },
                        )
                    }
                } else {
                    BasicText(
                        "No Message Selected",
                        modifier = Modifier
                            .fillMaxWidth()
                            .padding(vertical = 48.dp),
                        style = Typography.base.copy(color = colors.mutedForeground, textAlign = TextAlign.Center),
                    )
                }
            }
        }
    }
}

@Composable
private fun MailRow(mail: MailMessage, active: Boolean, onClick: () -> Unit) {
    val colors = EsuissTheme.colors
    SplitViewItem(isActive = active, onClick = onClick) {
        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            Box(
                Modifier
                    .padding(top = 6.dp)
                    .size(8.dp)
                    .background(if (mail.unread) colors.primary else Color.Transparent, CircleShape)
                    .semantics { if (mail.unread) stateDescription = "Unread" },
            )
            Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
                Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
                    BasicText(
                        mail.from,
                        modifier = Modifier.weight(1f),
                        style = Typography.sm.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis,
                    )
                    if (mail.starred) {
                        Icon(PhosphorIcons.Fill.FillStar, contentDescription = "Starred", size = 12.dp, tint = starTint(colors))
                    }
                    BasicText(mail.time, style = Typography.xs.copy(color = colors.mutedForeground))
                }
                BasicText(
                    mail.subject,
                    style = Typography.sm.copy(color = colors.foreground),
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis,
                )
                BasicText(
                    mail.body,
                    style = Typography.xs.copy(color = colors.mutedForeground),
                    maxLines = 2,
                    overflow = TextOverflow.Ellipsis,
                )
            }
        }
    }
}

@Composable
private fun MailSender(mail: MailMessage, size: Dp) {
    val colors = EsuissTheme.colors
    Box(
        Modifier
            .size(size)
            .background(senderTint(colors, mail.hue), CircleShape),
        contentAlignment = Alignment.Center,
    ) {
        BasicText(
            if (mail.hue != 0) mail.from.split(" ").mapNotNull { it.firstOrNull() }.joinToString("") else "s",
            style = Typography.base.copy(
                color = if (mail.hue != 0) Palette.white else colors.background,
                fontWeight = FontWeight.Medium,
                fontSize = (size.value * 0.4f).sp,
            ),
        )
    }
}

@Composable
private fun MailReader(
    mail: MailMessage,
    onReply: (String) -> Unit,
    onStar: () -> Unit,
    onArchive: () -> Unit,
    onDelete: () -> Unit,
) {
    val colors = EsuissTheme.colors
    var replying by rememberSaveable { mutableStateOf(false) }
    var reply by rememberSaveable { mutableStateOf("") }
    val appear = remember { Animatable(0f) }
    LaunchedEffect(Unit) { appear.animateTo(1f, tween(800, easing = MailRevealEasing)) }
    Column(Modifier.fillMaxWidth()) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .height(50.dp),
            horizontalArrangement = Arrangement.spacedBy(2.dp, Alignment.End),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            MailAction(PhosphorIcons.Regular.ArrowBendUpLeft, "Reply") { replying = true }
            MailAction(
                if (mail.starred) PhosphorIcons.Fill.FillStar else PhosphorIcons.Regular.Star,
                if (mail.starred) "Unstar" else "Star",
                tint = if (mail.starred) starTint(colors) else colors.mutedForeground,
                onClick = onStar,
            )
            MailAction(PhosphorIcons.Regular.Archive, "Archive", onClick = onArchive)
            MailAction(PhosphorIcons.Regular.Trash, "Delete", onClick = onDelete)
        }
        Box(
            Modifier
                .fillMaxWidth()
                .height(1.dp)
                .background(colors.border),
        )
        Column(
            modifier = Modifier
                .padding(top = 22.dp)
                .graphicsLayer {
                    alpha = appear.value
                    translationY = (1f - appear.value) * 8.dp.toPx()
                },
            verticalArrangement = Arrangement.spacedBy(16.dp),
        ) {
            Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
                MailSender(mail, 40.dp)
                Column(Modifier.weight(1f)) {
                    BasicText(mail.from, style = Typography.base.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold))
                    BasicText("To: Jamie Rivera", style = Typography.xs.copy(color = colors.mutedForeground))
                }
                BasicText(mail.time, style = Typography.xs.copy(color = colors.mutedForeground))
            }
            BasicText(
                mail.subject,
                modifier = Modifier.semantics { heading() },
                style = Typography.xl.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
            )
            BasicText(mail.body, style = Typography.base.copy(color = colors.foreground, lineHeight = 26.sp))
        }
        if (replying) {
            Column(
                modifier = Modifier.padding(top = 22.dp),
                verticalArrangement = Arrangement.spacedBy(10.dp),
            ) {
                Box(
                    Modifier
                        .fillMaxWidth()
                        .height(1.dp)
                        .background(colors.border),
                )
                Textarea(
                    value = reply,
                    onValueChange = { reply = it },
                    modifier = Modifier
                        .padding(top = 8.dp)
                        .semantics { contentDescription = "Reply to ${mail.from}" },
                    placeholder = "Reply to ${mail.from}",
                    minLines = 4,
                    keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
                )
                Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
                    Button(
                        text = "Send",
                        onClick = {
                            onReply(reply.trim())
                            reply = ""
                            replying = false
                        },
                        size = ButtonSize.Sm,
                        enabled = reply.isNotBlank(),
                    )
                    Button(text = "Cancel", onClick = { replying = false }, size = ButtonSize.Sm, variant = ButtonVariant.Outline)
                }
            }
        }
    }
}

@Composable
private fun MailAction(
    icon: ImageVector,
    label: String,
    tint: Color = EsuissTheme.colors.mutedForeground,
    onClick: () -> Unit,
) {
    Button(onClick = onClick, variant = ButtonVariant.Ghost, size = ButtonSize.Icon) {
        Icon(icon, contentDescription = label, size = 18.dp, tint = tint)
    }
}
```

A three-column mail screen built on `SplitView`: a sidebar of mailboxes (Inbox, Starred, Sent, Archive, Trash), a message list with a search field made from `InputGroup`, and a reader. On phones each column opens in turn. Rows use `Avatar` and support `SwipeActions` to archive or delete, and searching filters by sender, subject and body. The reader has Reply, Star, Archive and Delete buttons, and Reply opens a `Textarea`.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/inbox-mail
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add inbox-mail
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add inbox-mail --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add inbox-mail --platform kotlin
```

The CLI also adds what it builds on: [Avatar](https://ui.suiss.app/docs/components/avatar), [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Input Group](https://ui.suiss.app/docs/components/input-group), [Screen](https://ui.suiss.app/docs/components/screen), [Split View](https://ui.suiss.app/docs/patterns/split-view), [Swipe Actions](https://ui.suiss.app/docs/interactions/swipe-actions), [Text](https://ui.suiss.app/docs/components/text), [Textarea](https://ui.suiss.app/docs/components/textarea). On React Native it also adds the animated view `components/ui/animated-view.tsx`.

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Avatar](https://ui.suiss.app/docs/components/avatar), [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Input Group](https://ui.suiss.app/docs/components/input-group), [Screen](https://ui.suiss.app/docs/components/screen), [Split View](https://ui.suiss.app/docs/patterns/split-view), [Swipe Actions](https://ui.suiss.app/docs/interactions/swipe-actions), [Text](https://ui.suiss.app/docs/components/text), [Textarea](https://ui.suiss.app/docs/components/textarea), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `uniwind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/uniwind/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/inbox-mail.tsx`:

- `uniwind/components/blocks/inbox-mail.tsx`: shown above

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Avatar](https://ui.suiss.app/docs/components/avatar), [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Input Group](https://ui.suiss.app/docs/components/input-group), [Screen](https://ui.suiss.app/docs/components/screen), [Split View](https://ui.suiss.app/docs/patterns/split-view), [Swipe Actions](https://ui.suiss.app/docs/interactions/swipe-actions), [Text](https://ui.suiss.app/docs/components/text), [Textarea](https://ui.suiss.app/docs/components/textarea), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `nativewind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/nativewind/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/inbox-mail.tsx`:

```tsx title="nativewind/components/blocks/inbox-mail.tsx"
import { cn } from "cn"
import {
  ArchiveIcon,
  ArrowBendUpLeftIcon,
  MagnifyingGlassIcon,
  NotePencilIcon,
  PaperPlaneTiltIcon,
  type Icon as PhosphorIcon,
  StarIcon,
  TrashIcon,
  TrayIcon,
} from "phosphor-react-native"
import * as React from "react"
import { useWindowDimensions, View } from "react-native"
import { FadeInDown, ReduceMotion } from "react-native-reanimated"
import {
  SwipeAction,
  SwipeActions,
  SwipeActionsActions,
  SwipeActionsContent,
} from "@/components/interactions/swipe-actions"
import {
  SplitView,
  type SplitViewColumn,
  SplitViewDetail,
  SplitViewItem,
  SplitViewList,
  SplitViewSidebar,
} from "@/components/patterns/split-view"
import { AnimatedView } from "@/components/ui/animated-view"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"
import { Textarea } from "@/components/ui/textarea"

type Mail = {
  id: number
  from: string
  subject: string
  body: string
  time: string
  tint: string | null
  unread: boolean
  starred: boolean
}

const INITIAL_MAIL: Mail[] = [
  {
    id: 0,
    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",
    tint: null,
    unread: true,
    starred: false,
  },
  {
    id: 1,
    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",
    tint: "bg-orange-500",
    unread: true,
    starred: false,
  },
  {
    id: 2,
    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",
    tint: "bg-blue-500",
    unread: true,
    starred: false,
  },
  {
    id: 3,
    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",
    tint: "bg-green-500",
    unread: false,
    starred: true,
  },
  {
    id: 4,
    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",
    tint: null,
    unread: false,
    starred: false,
  },
  {
    id: 5,
    from: "App Store",
    subject: "Your receipt",
    body: "Your Studio Pro subscription renewed. Total: $4.99.",
    time: "Monday",
    tint: null,
    unread: false,
    starred: false,
  },
]

const MAILBOXES: { value: string; icon: PhosphorIcon }[] = [
  { value: "Inbox", icon: TrayIcon },
  { value: "Starred", icon: StarIcon },
  { value: "Sent", icon: PaperPlaneTiltIcon },
  { value: "Archive", icon: ArchiveIcon },
  { value: "Trash", icon: TrashIcon },
]

const ENTERING = FadeInDown.duration(800).reduceMotion(ReduceMotion.System)

function SenderAvatar({ mail }: { mail: Mail }) {
  return (
    <Avatar size="lg">
      <AvatarFallback
        className={mail.tint ?? "bg-foreground dark:bg-foreground"}
        accessibilityLabel={mail.from}
      >
        <Text className={mail.tint ? undefined : "text-background normal-case"}>
          {mail.tint
            ? mail.from
                .split(" ")
                .map((part) => part[0])
                .join("")
            : "s"}
        </Text>
      </AvatarFallback>
    </Avatar>
  )
}

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

  const actions: {
    label: string
    icon: PhosphorIcon
    onPress: () => void
    active?: boolean
  }[] = [
    {
      label: "Reply",
      icon: ArrowBendUpLeftIcon,
      onPress: () => setReplying(true),
    },
    {
      label: mail.starred ? "Unstar" : "Star",
      icon: StarIcon,
      onPress: onStar,
      active: mail.starred,
    },
    { label: "Archive", icon: ArchiveIcon, onPress: onArchive },
    { label: "Delete", icon: TrashIcon, onPress: onDelete },
  ]

  return (
    <View className="flex-1">
      <View className="h-[3.125rem] flex-row items-center justify-end gap-0.5 border-b border-border">
        {actions.map((action) => (
          <Button
            key={action.label}
            variant="ghost"
            size="icon"
            accessibilityLabel={action.label}
            accessibilityState={
              action.active === undefined
                ? undefined
                : { selected: action.active }
            }
            onPress={action.onPress}
          >
            <Icon
              as={action.icon}
              weight={action.active ? "fill" : "regular"}
              className={cn(
                "size-5",
                action.active ? "text-orange-500" : "text-muted-foreground"
              )}
            />
          </Button>
        ))}
      </View>
      <AnimatedView
        key={mail.id}
        entering={ENTERING}
        className="gap-4 pt-[1.375rem]"
      >
        <View className="flex-row items-center gap-3">
          <SenderAvatar mail={mail} />
          <View className="flex-1">
            <Text className="text-base font-semibold">{mail.from}</Text>
            <Text className="text-xs text-muted-foreground">
              To: Jamie Rivera
            </Text>
          </View>
          <Text className="text-xs text-muted-foreground">{mail.time}</Text>
        </View>
        <Text role="heading" className="text-2xl font-semibold">
          {mail.subject}
        </Text>
        <Text className="text-base">{mail.body}</Text>
      </AnimatedView>
      {replying ? (
        <AnimatedView
          entering={ENTERING}
          className="mt-[1.375rem] gap-2.5 border-t border-border pt-[1.125rem]"
        >
          <Textarea
            autoFocus
            numberOfLines={4}
            value={reply}
            onChangeText={setReply}
            placeholder={`Reply to ${mail.from}`}
            accessibilityLabel={`Reply to ${mail.from}`}
            className="min-h-24"
          />
          <View className="flex-row gap-2.5">
            <Button
              size="sm"
              disabled={!reply}
              onPress={() => {
                onReply(reply)
                setReplying(false)
                setReply("")
              }}
            >
              <Text>Send</Text>
            </Button>
            <Button
              size="sm"
              variant="outline"
              onPress={() => setReplying(false)}
            >
              <Text>Cancel</Text>
            </Button>
          </View>
        </AnimatedView>
      ) : null}
    </View>
  )
}

function MailRow({
  mail,
  index,
  active,
  onPress,
  onArchive,
  onDelete,
}: {
  mail: Mail
  index: number
  active: boolean
  onPress: () => void
  onArchive: () => void
  onDelete: () => void
}) {
  const muted = active ? "text-primary-foreground" : "text-muted-foreground"
  return (
    <AnimatedView
      entering={FadeInDown.delay(index * 40)
        .duration(700)
        .reduceMotion(ReduceMotion.System)}
    >
      <SwipeActions>
        <SwipeActionsContent className="bg-transparent">
          <SplitViewItem
            isActive={active}
            onPress={onPress}
            accessibilityHint={
              [mail.unread && "Unread", mail.starred && "Starred"]
                .filter(Boolean)
                .join(", ") || undefined
            }
            className={cn("relative py-2.5 ps-6", active && "bg-primary")}
          >
            {mail.unread ? (
              <View
                className={cn(
                  "absolute -start-3.5 top-1.5 size-2 rounded-full",
                  active ? "bg-primary-foreground" : "bg-primary"
                )}
              />
            ) : null}
            <View className="flex-row items-center gap-1.5">
              <Text
                numberOfLines={1}
                className={cn(
                  "flex-1 text-sm font-semibold",
                  active && "text-primary-foreground"
                )}
              >
                {mail.from}
              </Text>
              {mail.starred ? (
                <Icon
                  as={StarIcon}
                  weight="fill"
                  className={cn(
                    "size-3",
                    active ? "text-primary-foreground" : "text-orange-500"
                  )}
                />
              ) : null}
              <Text className={cn("text-xs", muted)}>{mail.time}</Text>
            </View>
            <Text
              numberOfLines={1}
              className={cn("text-sm", active && "text-primary-foreground")}
            >
              {mail.subject}
            </Text>
            <Text numberOfLines={2} className={cn("text-xs", muted)}>
              {mail.body}
            </Text>
          </SplitViewItem>
        </SwipeActionsContent>
        <SwipeActionsActions side="trailing">
          <SwipeAction variant="archive" onPress={onArchive}>
            <Icon as={ArchiveIcon} />
            <Text>Archive</Text>
          </SwipeAction>
          <SwipeAction variant="destructive" fullSwipe onPress={onDelete}>
            <Icon as={TrashIcon} />
            <Text>Delete</Text>
          </SwipeAction>
        </SwipeActionsActions>
      </SwipeActions>
    </AnimatedView>
  )
}

function InboxMail({
  onMailboxChange,
  onNewMessage,
  onReply,
  onStarChange,
  onArchive,
  onDelete,
}: {
  onMailboxChange?: (mailbox: string) => void
  onNewMessage?: () => void
  onReply?: (id: number, text: string) => void
  onStarChange?: (id: number, starred: boolean) => void
  onArchive?: (id: number) => void
  onDelete?: (id: number) => void
}) {
  const { width } = useWindowDimensions()
  const wide = width >= 768
  const [mail, setMail] = React.useState(INITIAL_MAIL)
  const [mailbox, setMailbox] = React.useState("Inbox")
  const [selected, setSelected] = React.useState<number | null>(0)
  const [query, setQuery] = React.useState("")
  const [column, setColumn] = React.useState<SplitViewColumn>("list")
  const unread = mail.filter((item) => item.unread).length
  const starred = mail.filter((item) => item.starred).length
  const counts: Record<string, number> = { Inbox: unread, Starred: starred }
  const list = mail.filter(
    (item) =>
      (mailbox !== "Starred" || item.starred) &&
      (!query ||
        `${item.from} ${item.subject} ${item.body}`
          .toLowerCase()
          .includes(query.toLowerCase()))
  )
  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 remove = (id: number) => {
    const rest = mail.filter((item) => item.id !== id)
    setMail(rest)
    if (selected === id) {
      setSelected(rest[0]?.id ?? null)
      setColumn("list")
    }
  }

  return (
    <Screen edges={["top", "right", "left"]}>
      <SplitView
        column={column}
        onColumnChange={setColumn}
        className={wide ? "m-4" : undefined}
      >
        <SplitViewSidebar
          title="Mailboxes"
          className={wide ? "min-h-full bg-muted" : "px-4 pb-8"}
        >
          {MAILBOXES.map((box) => (
            <SplitViewItem
              key={box.value}
              isActive={mailbox === box.value}
              onPress={() => {
                setMailbox(box.value)
                onMailboxChange?.(box.value)
              }}
              className={
                wide && mailbox === box.value ? "bg-secondary" : undefined
              }
            >
              <View className="flex-row items-center gap-2.5">
                <Icon as={box.icon} className="size-5 text-primary" />
                <Text className="flex-1">{box.value}</Text>
                <Text className="text-xs text-muted-foreground tabular-nums">
                  {counts[box.value] ? String(counts[box.value]) : ""}
                </Text>
              </View>
            </SplitViewItem>
          ))}
        </SplitViewSidebar>
        <SplitViewList
          title={mailbox}
          className={wide ? "gap-2 px-2 pt-3" : "gap-2 px-4 pb-8"}
        >
          <View
            className={cn(
              "flex-row items-center gap-2",
              wide ? "px-1" : "absolute end-2 top-0"
            )}
          >
            {wide ? (
              <View className="flex-1">
                <Text className="text-lg font-semibold">{mailbox}</Text>
                <Text className="text-xs text-muted-foreground">
                  {unread ? `${unread} unread` : "All read"}
                </Text>
              </View>
            ) : null}
            <Button
              variant="ghost"
              size="icon"
              accessibilityLabel="New message"
              onPress={onNewMessage}
            >
              <Icon as={NotePencilIcon} className="size-5 text-primary" />
            </Button>
          </View>
          <InputGroup className={cn("h-9 rounded-[0.625rem]", wide && "mx-1")}>
            <InputGroupAddon>
              <Icon
                as={MagnifyingGlassIcon}
                weight="bold"
                className="size-4 text-muted-foreground"
              />
            </InputGroupAddon>
            <InputGroupInput
              value={query}
              onChangeText={setQuery}
              placeholder="Search"
              accessibilityLabel="Search mail"
              returnKeyType="search"
              autoCorrect={false}
              clearButtonMode="while-editing"
            />
          </InputGroup>
          <View className="gap-0.5">
            {list.length > 0 ? (
              list.map((item, index) => (
                <MailRow
                  key={item.id}
                  mail={item}
                  index={index}
                  active={wide && item.id === selected}
                  onPress={() => {
                    setSelected(item.id)
                    update(item.id, { unread: false })
                  }}
                  onArchive={() => {
                    onArchive?.(item.id)
                    remove(item.id)
                  }}
                  onDelete={() => {
                    onDelete?.(item.id)
                    remove(item.id)
                  }}
                />
              ))
            ) : (
              <Text className="p-6 text-center text-sm text-muted-foreground">
                No Results
              </Text>
            )}
          </View>
        </SplitViewList>
        <SplitViewDetail className={wide ? undefined : "grow px-4 pb-8"}>
          {current ? (
            <Reader
              mail={current}
              onReply={(text) => onReply?.(current.id, text)}
              onStar={() => {
                onStarChange?.(current.id, !current.starred)
                update(current.id, { starred: !current.starred })
              }}
              onArchive={() => {
                onArchive?.(current.id)
                remove(current.id)
              }}
              onDelete={() => {
                onDelete?.(current.id)
                remove(current.id)
              }}
            />
          ) : (
            <View className="flex-1 items-center justify-center py-20">
              <Text className="text-muted-foreground">No Message Selected</Text>
            </View>
          )}
        </SplitViewDetail>
      </SplitView>
    </Screen>
  )
}

export { InboxMail }
```

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Avatar](https://ui.suiss.app/docs/components/avatar), [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Input Group](https://ui.suiss.app/docs/components/input-group), [Screen](https://ui.suiss.app/docs/components/screen), [Split View](https://ui.suiss.app/docs/patterns/split-view), [Swipe Actions](https://ui.suiss.app/docs/interactions/swipe-actions), [Text](https://ui.suiss.app/docs/components/text), [Textarea](https://ui.suiss.app/docs/components/textarea), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `stylesheet/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/stylesheet/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/inbox-mail.tsx`:

```tsx title="stylesheet/components/blocks/inbox-mail.tsx"
import {
  ArchiveIcon,
  ArrowBendUpLeftIcon,
  MagnifyingGlassIcon,
  NotePencilIcon,
  PaperPlaneTiltIcon,
  type Icon as PhosphorIcon,
  StarIcon,
  TrashIcon,
  TrayIcon,
} from "phosphor-react-native"
import * as React from "react"
import { StyleSheet, useWindowDimensions, View } from "react-native"
import { FadeInDown, ReduceMotion } from "react-native-reanimated"
import {
  SwipeAction,
  SwipeActions,
  SwipeActionsActions,
  SwipeActionsContent,
} from "@/components/interactions/swipe-actions"
import {
  SplitView,
  type SplitViewColumn,
  SplitViewDetail,
  SplitViewItem,
  SplitViewList,
  SplitViewSidebar,
} from "@/components/patterns/split-view"
import { AnimatedView } from "@/components/ui/animated-view"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"
import { Textarea } from "@/components/ui/textarea"
import {
  fontSize,
  palette,
  radius,
  type ThemeColors,
  useTheme,
} from "@/components/ui/theme"

type Tone = keyof ThemeColors | keyof typeof palette

function tone(colors: ThemeColors, key: Tone) {
  return { ...colors, ...palette }[key]
}

type Mail = {
  id: number
  from: string
  subject: string
  body: string
  time: string
  tint: Tone | null
  unread: boolean
  starred: boolean
}

const INITIAL_MAIL: Mail[] = [
  {
    id: 0,
    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",
    tint: null,
    unread: true,
    starred: false,
  },
  {
    id: 1,
    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",
    tint: "orange500",
    unread: true,
    starred: false,
  },
  {
    id: 2,
    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",
    tint: "blue500",
    unread: true,
    starred: false,
  },
  {
    id: 3,
    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",
    tint: "green500",
    unread: false,
    starred: true,
  },
  {
    id: 4,
    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",
    tint: null,
    unread: false,
    starred: false,
  },
  {
    id: 5,
    from: "App Store",
    subject: "Your receipt",
    body: "Your Studio Pro subscription renewed. Total: $4.99.",
    time: "Monday",
    tint: null,
    unread: false,
    starred: false,
  },
]

const MAILBOXES: { value: string; icon: PhosphorIcon }[] = [
  { value: "Inbox", icon: TrayIcon },
  { value: "Starred", icon: StarIcon },
  { value: "Sent", icon: PaperPlaneTiltIcon },
  { value: "Archive", icon: ArchiveIcon },
  { value: "Trash", icon: TrashIcon },
]

const ENTERING = FadeInDown.duration(800).reduceMotion(ReduceMotion.System)

const styles = StyleSheet.create({
  reader: { flex: 1 },
  toolbar: {
    height: 50,
    flexDirection: "row",
    alignItems: "center",
    justifyContent: "flex-end",
    gap: 2,
    borderBottomWidth: 1,
  },
  message: { gap: 16, paddingTop: 22 },
  sender: { flexDirection: "row", alignItems: "center", gap: 12 },
  senderText: { flex: 1 },
  semibold: { ...fontSize.base, fontWeight: "600" },
  subject: { ...fontSize["2xl"], fontWeight: "600" },
  reply: { marginTop: 22, gap: 10, borderTopWidth: 1, paddingTop: 18 },
  textarea: { minHeight: 96 },
  buttons: { flexDirection: "row", gap: 10 },
  transparent: { backgroundColor: "transparent" },
  row: { position: "relative", paddingVertical: 10, paddingStart: 24 },
  dot: {
    position: "absolute",
    start: -14,
    top: 6,
    width: 8,
    height: 8,
    borderRadius: radius.full,
  },
  rowTop: { flexDirection: "row", alignItems: "center", gap: 6 },
  from: { ...fontSize.sm, flex: 1, fontWeight: "600" },
  wide: { margin: 16 },
  mailbox: { flexDirection: "row", alignItems: "center", gap: 10 },
  fill: { flex: 1 },
  count: { ...fontSize.xs, fontVariant: ["tabular-nums"] },
  sidebar: { paddingHorizontal: 16, paddingBottom: 32 },
  listWide: { gap: 8, paddingHorizontal: 8, paddingTop: 12 },
  list: { gap: 8, paddingHorizontal: 16, paddingBottom: 32 },
  listTop: { flexDirection: "row", alignItems: "center", gap: 8 },
  listTopWide: { paddingHorizontal: 4 },
  listTopCompact: { position: "absolute", end: 8, top: 0 },
  mailboxTitle: { ...fontSize.lg, fontWeight: "600" },
  search: { height: 36, borderRadius: 10 },
  searchWide: { marginHorizontal: 4 },
  rows: { gap: 2 },
  empty: { ...fontSize.sm, padding: 24, textAlign: "center" },
  detail: { flexGrow: 1, paddingHorizontal: 16, paddingBottom: 32 },
  none: {
    flex: 1,
    alignItems: "center",
    justifyContent: "center",
    paddingVertical: 80,
  },
})

function SenderAvatar({ mail }: { mail: Mail }) {
  const { colors } = useTheme()
  return (
    <Avatar size="lg">
      <AvatarFallback
        style={{ backgroundColor: tone(colors, mail.tint ?? "foreground") }}
        accessibilityLabel={mail.from}
      >
        <Text
          style={
            mail.tint
              ? undefined
              : { color: colors.background, textTransform: "none" }
          }
        >
          {mail.tint
            ? mail.from
                .split(" ")
                .map((part) => part[0])
                .join("")
            : "s"}
        </Text>
      </AvatarFallback>
    </Avatar>
  )
}

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

  const actions: {
    label: string
    icon: PhosphorIcon
    onPress: () => void
    active?: boolean
  }[] = [
    {
      label: "Reply",
      icon: ArrowBendUpLeftIcon,
      onPress: () => setReplying(true),
    },
    {
      label: mail.starred ? "Unstar" : "Star",
      icon: StarIcon,
      onPress: onStar,
      active: mail.starred,
    },
    { label: "Archive", icon: ArchiveIcon, onPress: onArchive },
    { label: "Delete", icon: TrashIcon, onPress: onDelete },
  ]

  return (
    <View style={styles.reader}>
      <View style={[styles.toolbar, { borderBottomColor: colors.border }]}>
        {actions.map((action) => (
          <Button
            key={action.label}
            variant="ghost"
            size="icon"
            accessibilityLabel={action.label}
            accessibilityState={
              action.active === undefined
                ? undefined
                : { selected: action.active }
            }
            onPress={action.onPress}
          >
            <Icon
              as={action.icon}
              weight={action.active ? "fill" : "regular"}
              size={20}
              color={action.active ? palette.orange500 : colors.mutedForeground}
            />
          </Button>
        ))}
      </View>
      <AnimatedView key={mail.id} entering={ENTERING} style={styles.message}>
        <View style={styles.sender}>
          <SenderAvatar mail={mail} />
          <View style={styles.senderText}>
            <Text style={styles.semibold}>{mail.from}</Text>
            <Text style={[fontSize.xs, { color: colors.mutedForeground }]}>
              To: Jamie Rivera
            </Text>
          </View>
          <Text style={[fontSize.xs, { color: colors.mutedForeground }]}>
            {mail.time}
          </Text>
        </View>
        <Text role="heading" style={styles.subject}>
          {mail.subject}
        </Text>
        <Text style={fontSize.base}>{mail.body}</Text>
      </AnimatedView>
      {replying ? (
        <AnimatedView
          entering={ENTERING}
          style={[styles.reply, { borderTopColor: colors.border }]}
        >
          <Textarea
            autoFocus
            numberOfLines={4}
            value={reply}
            onChangeText={setReply}
            placeholder={`Reply to ${mail.from}`}
            accessibilityLabel={`Reply to ${mail.from}`}
            style={styles.textarea}
          />
          <View style={styles.buttons}>
            <Button
              size="sm"
              disabled={!reply}
              onPress={() => {
                onReply(reply)
                setReplying(false)
                setReply("")
              }}
            >
              <Text>Send</Text>
            </Button>
            <Button
              size="sm"
              variant="outline"
              onPress={() => setReplying(false)}
            >
              <Text>Cancel</Text>
            </Button>
          </View>
        </AnimatedView>
      ) : null}
    </View>
  )
}

function MailRow({
  mail,
  index,
  active,
  onPress,
  onArchive,
  onDelete,
}: {
  mail: Mail
  index: number
  active: boolean
  onPress: () => void
  onArchive: () => void
  onDelete: () => void
}) {
  const { colors } = useTheme()
  const muted = {
    color: active ? colors.primaryForeground : colors.mutedForeground,
  }
  return (
    <AnimatedView
      entering={FadeInDown.delay(index * 40)
        .duration(700)
        .reduceMotion(ReduceMotion.System)}
    >
      <SwipeActions>
        <SwipeActionsContent style={styles.transparent}>
          <SplitViewItem
            isActive={active}
            onPress={onPress}
            accessibilityHint={
              [mail.unread && "Unread", mail.starred && "Starred"]
                .filter(Boolean)
                .join(", ") || undefined
            }
            style={
              active
                ? { ...styles.row, backgroundColor: colors.primary }
                : styles.row
            }
          >
            {mail.unread ? (
              <View
                style={[
                  styles.dot,
                  {
                    backgroundColor: active
                      ? colors.primaryForeground
                      : colors.primary,
                  },
                ]}
              />
            ) : null}
            <View style={styles.rowTop}>
              <Text
                numberOfLines={1}
                style={[
                  styles.from,
                  active && { color: colors.primaryForeground },
                ]}
              >
                {mail.from}
              </Text>
              {mail.starred ? (
                <Icon
                  as={StarIcon}
                  weight="fill"
                  size={12}
                  color={active ? colors.primaryForeground : palette.orange500}
                />
              ) : null}
              <Text style={[fontSize.xs, muted]}>{mail.time}</Text>
            </View>
            <Text
              numberOfLines={1}
              style={[
                fontSize.sm,
                active && { color: colors.primaryForeground },
              ]}
            >
              {mail.subject}
            </Text>
            <Text numberOfLines={2} style={[fontSize.xs, muted]}>
              {mail.body}
            </Text>
          </SplitViewItem>
        </SwipeActionsContent>
        <SwipeActionsActions side="trailing">
          <SwipeAction variant="archive" onPress={onArchive}>
            <Icon as={ArchiveIcon} />
            <Text>Archive</Text>
          </SwipeAction>
          <SwipeAction variant="destructive" fullSwipe onPress={onDelete}>
            <Icon as={TrashIcon} />
            <Text>Delete</Text>
          </SwipeAction>
        </SwipeActionsActions>
      </SwipeActions>
    </AnimatedView>
  )
}

function InboxMail({
  onMailboxChange,
  onNewMessage,
  onReply,
  onStarChange,
  onArchive,
  onDelete,
}: {
  onMailboxChange?: (mailbox: string) => void
  onNewMessage?: () => void
  onReply?: (id: number, text: string) => void
  onStarChange?: (id: number, starred: boolean) => void
  onArchive?: (id: number) => void
  onDelete?: (id: number) => void
}) {
  const { colors } = useTheme()
  const { width } = useWindowDimensions()
  const wide = width >= 768
  const [mail, setMail] = React.useState(INITIAL_MAIL)
  const [mailbox, setMailbox] = React.useState("Inbox")
  const [selected, setSelected] = React.useState<number | null>(0)
  const [query, setQuery] = React.useState("")
  const [column, setColumn] = React.useState<SplitViewColumn>("list")
  const unread = mail.filter((item) => item.unread).length
  const starred = mail.filter((item) => item.starred).length
  const counts: Record<string, number> = { Inbox: unread, Starred: starred }
  const list = mail.filter(
    (item) =>
      (mailbox !== "Starred" || item.starred) &&
      (!query ||
        `${item.from} ${item.subject} ${item.body}`
          .toLowerCase()
          .includes(query.toLowerCase()))
  )
  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 remove = (id: number) => {
    const rest = mail.filter((item) => item.id !== id)
    setMail(rest)
    if (selected === id) {
      setSelected(rest[0]?.id ?? null)
      setColumn("list")
    }
  }

  return (
    <Screen edges={["top", "right", "left"]}>
      <SplitView
        column={column}
        onColumnChange={setColumn}
        style={wide ? styles.wide : undefined}
      >
        <SplitViewSidebar
          title="Mailboxes"
          style={
            wide
              ? { minHeight: "100%", backgroundColor: colors.muted }
              : styles.sidebar
          }
        >
          {MAILBOXES.map((box) => (
            <SplitViewItem
              key={box.value}
              isActive={mailbox === box.value}
              onPress={() => {
                setMailbox(box.value)
                onMailboxChange?.(box.value)
              }}
              style={
                wide && mailbox === box.value
                  ? { backgroundColor: colors.secondary }
                  : undefined
              }
            >
              <View style={styles.mailbox}>
                <Icon as={box.icon} size={20} color={colors.primary} />
                <Text style={styles.fill}>{box.value}</Text>
                <Text style={[styles.count, { color: colors.mutedForeground }]}>
                  {counts[box.value] ? String(counts[box.value]) : ""}
                </Text>
              </View>
            </SplitViewItem>
          ))}
        </SplitViewSidebar>
        <SplitViewList
          title={mailbox}
          style={wide ? styles.listWide : styles.list}
        >
          <View
            style={[
              styles.listTop,
              wide ? styles.listTopWide : styles.listTopCompact,
            ]}
          >
            {wide ? (
              <View style={styles.fill}>
                <Text style={styles.mailboxTitle}>{mailbox}</Text>
                <Text style={[fontSize.xs, { color: colors.mutedForeground }]}>
                  {unread ? `${unread} unread` : "All read"}
                </Text>
              </View>
            ) : null}
            <Button
              variant="ghost"
              size="icon"
              accessibilityLabel="New message"
              onPress={onNewMessage}
            >
              <Icon as={NotePencilIcon} size={20} color={colors.primary} />
            </Button>
          </View>
          <InputGroup style={[styles.search, wide && styles.searchWide]}>
            <InputGroupAddon>
              <Icon
                as={MagnifyingGlassIcon}
                weight="bold"
                size={16}
                color={colors.mutedForeground}
              />
            </InputGroupAddon>
            <InputGroupInput
              value={query}
              onChangeText={setQuery}
              placeholder="Search"
              accessibilityLabel="Search mail"
              returnKeyType="search"
              autoCorrect={false}
              clearButtonMode="while-editing"
            />
          </InputGroup>
          <View style={styles.rows}>
            {list.length > 0 ? (
              list.map((item, index) => (
                <MailRow
                  key={item.id}
                  mail={item}
                  index={index}
                  active={wide && item.id === selected}
                  onPress={() => {
                    setSelected(item.id)
                    update(item.id, { unread: false })
                  }}
                  onArchive={() => {
                    onArchive?.(item.id)
                    remove(item.id)
                  }}
                  onDelete={() => {
                    onDelete?.(item.id)
                    remove(item.id)
                  }}
                />
              ))
            ) : (
              <Text style={[styles.empty, { color: colors.mutedForeground }]}>
                No Results
              </Text>
            )}
          </View>
        </SplitViewList>
        <SplitViewDetail style={wide ? undefined : styles.detail}>
          {current ? (
            <Reader
              mail={current}
              onReply={(text) => onReply?.(current.id, text)}
              onStar={() => {
                onStarChange?.(current.id, !current.starred)
                update(current.id, { starred: !current.starred })
              }}
              onArchive={() => {
                onArchive?.(current.id)
                remove(current.id)
              }}
              onDelete={() => {
                onDelete?.(current.id)
                remove(current.id)
              }}
            />
          ) : (
            <View style={styles.none}>
              <Text style={{ color: colors.mutedForeground }}>
                No Message Selected
              </Text>
            </View>
          )}
        </SplitViewDetail>
      </SplitView>
    </Screen>
  )
}

export { InboxMail }
```

5. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

- `swift/Blocks/InboxMail.swift`: shown above

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/Checkbox.swift`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `swift/Components/DropdownMenu.swift`: see [Dropdown Menu](https://ui.suiss.app/docs/components/dropdown-menu.md)

- `swift/Components/Field.swift`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `swift/Components/Haptics.swift`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `swift/Components/Input.swift`: see [Input](https://ui.suiss.app/docs/components/input.md)

- `swift/Components/InputGroup.swift`: see [Input Group](https://ui.suiss.app/docs/components/input-group.md)

- `swift/Components/KeyboardScrollView.swift`: see [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view.md)

- `swift/Components/Overlay.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/Overlay.swift)

- `swift/Components/Screen.swift`: see [Screen](https://ui.suiss.app/docs/components/screen.md)

- `swift/Components/Select.swift`: see [Select](https://ui.suiss.app/docs/components/select.md)

- `swift/Components/Separator.swift`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

- `swift/Components/Textarea.swift`: see [Textarea](https://ui.suiss.app/docs/components/textarea.md)

- `swift/Interactions/SwipeActions.swift`: see [Swipe Actions](https://ui.suiss.app/docs/interactions/swipe-actions.md)

- `swift/Patterns/SplitView.swift`: see [Split View](https://ui.suiss.app/docs/patterns/split-view.md)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

- `kotlin/blocks/InboxMail.kt`: shown above

- `kotlin/components/Button.kt`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `kotlin/components/ButtonGroup.kt`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Field.kt`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `kotlin/components/FormSupport.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/FormSupport.kt)

- `kotlin/components/Haptics.kt`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

- `kotlin/components/InputGroup.kt`: see [Input Group](https://ui.suiss.app/docs/components/input-group.md)

- `kotlin/components/Label.kt`: see [Label](https://ui.suiss.app/docs/components/label.md)

- `kotlin/components/Screen.kt`: see [Screen](https://ui.suiss.app/docs/components/screen.md)

- `kotlin/components/Separator.kt`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `kotlin/components/Textarea.kt`: see [Textarea](https://ui.suiss.app/docs/components/textarea.md)

- `kotlin/interactions/SwipeActions.kt`: see [Swipe Actions](https://ui.suiss.app/docs/interactions/swipe-actions.md)

- `kotlin/patterns/SplitView.kt`: see [Split View](https://ui.suiss.app/docs/patterns/split-view.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { InboxMail } from "@/components/blocks/inbox-mail"
```

```tsx
<InboxMail />
```

Replace `INITIAL_MAIL` with your messages and `MAILBOXES` with your folders. Wire `onMailboxChange`, `onNewMessage`, `onReply`, `onStarChange`, `onArchive` and `onDelete` to your mail service.

