# Inbox Categories

A mail inbox sorted into Primary, Transactions, Updates, and Promotions, with a message reader and inline reply.

Inbox Categories 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-categories

**React**

**Example**

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

import {
  ArchiveIcon,
  ArrowBendUpLeftIcon,
  BellIcon,
  CreditCardIcon,
  StarIcon,
  TagIcon,
  TrashIcon,
  UsersIcon,
} from "@phosphor-icons/react"
import * as React from "react"

import {
  SplitView,
  SplitViewDetail,
  SplitViewItem,
  SplitViewList,
} from "@/components/patterns/split-view"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

type Category = "Primary" | "Transactions" | "Updates" | "Promotions"

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

const initialMail: 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",
    hue: 0,
    category: "Transactions",
    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",
    hue: 30,
    category: "Primary",
    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",
    hue: 210,
    category: "Updates",
    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",
    hue: 150,
    category: "Primary",
    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",
    hue: 0,
    category: "Promotions",
    unread: false,
    starred: false,
  },
  {
    id: 5,
    from: "App Store",
    subject: "Your receipt",
    body: "Your Studio Pro subscription renewed. Total: $4.99.",
    time: "Monday",
    hue: 0,
    category: "Transactions",
    unread: false,
    starred: false,
  },
]

const categories: { value: Category; icon: React.ReactNode; tint: string }[] = [
  {
    value: "Primary",
    icon: (
      <UsersIcon className="size-4" />
    ),
    tint: "aria-pressed:bg-primary",
  },
  {
    value: "Transactions",
    icon: (
      <CreditCardIcon className="size-4" />
    ),
    tint: "aria-pressed:bg-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_40%)] dark:aria-pressed:bg-[color-mix(in_oklab,var(--color-green-500),black_40%)]",
  },
  {
    value: "Updates",
    icon: (
      <BellIcon className="size-4" />
    ),
    tint: "aria-pressed:bg-[color-mix(in_oklab,var(--color-purple-500),var(--foreground)_20%)] dark:aria-pressed:bg-[color-mix(in_oklab,var(--color-purple-500),black_30%)]",
  },
  {
    value: "Promotions",
    icon: (
      <TagIcon className="size-4" />
    ),
    tint: "aria-pressed:bg-[color-mix(in_oklab,var(--color-pink-500),var(--foreground)_20%)] dark:aria-pressed:bg-[color-mix(in_oklab,var(--color-pink-500),black_25%)]",
  },
]

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,
  onRemove,
}: {
  mail: Mail
  onStar: () => void
  onRemove: () => 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: onRemove,
    },
    {
      label: "Delete",
      icon: (
        <TrashIcon className="size-4.5" />
      ),
      onClick: onRemove,
    },
  ]

  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 InboxCategories() {
  const [mail, setMail] = React.useState(initialMail)
  const [category, setCategory] = React.useState<Category>("Primary")
  const [selected, setSelected] = React.useState<number | null>(1)
  const list = mail.filter((item) => item.category === category)
  const current = list.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)
    setSelected(rest.find((item) => item.category === category)?.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"
    >
      <SplitViewList title="Inbox" className="gap-3 md:px-2 md:pt-4">
        <p className="text-3xl font-bold tracking-tight max-md:hidden md:px-1.5">
          Inbox
        </p>
        <ToggleGroup
          value={[category]}
          onValueChange={(value: string[]) => {
            const next = value[0] as Category | undefined
            if (!next) return
            setCategory(next)
            setSelected(mail.find((item) => item.category === next)?.id ?? null)
          }}
          aria-label="Category"
          spacing={1.5}
          className="md:px-1.5"
        >
          {categories.map((item) => {
            const unread = mail.some(
              (entry) => entry.category === item.value && entry.unread
            )
            const pressed = category === item.value
            return (
              <ToggleGroupItem
                key={item.value}
                value={item.value}
                aria-label={item.value}
                className={`relative h-8.5 gap-1.5 bg-secondary px-2.75 font-semibold text-muted-foreground transition-[padding,background-color,color] duration-350 ease-[cubic-bezier(0.3,1.25,0.5,1)] hover:bg-secondary/80 aria-pressed:px-3.5 aria-pressed:text-white aria-pressed:hover:text-white ${item.tint}`}
              >
                {item.icon}
                {pressed && <span aria-hidden>{item.value}</span>}
                {!pressed && unread && (
                  <span className="absolute end-0.5 top-0.5 size-2 rounded-full bg-primary ring-2 ring-background">
                    <span className="sr-only">Unread</span>
                  </span>
                )}
              </ToggleGroupItem>
            )
          })}
        </ToggleGroup>
        <div key={category} className="flex flex-col gap-0.5">
          {list.length > 0 ? (
            list.map((item, index) => (
              <SplitViewItem
                key={item.id}
                isActive={item.id === selected}
                onClick={() => {
                  setSelected(item.id)
                  update(item.id, { unread: false })
                }}
                style={{ transitionDelay: `${index * 40}ms` }}
                className="group/mail relative py-2.5 ps-6 transition-[opacity,translate] duration-700 starting:translate-y-1.5 starting:opacity-0 motion-reduce:transition-none md:data-active:bg-primary md:data-active:text-primary-foreground"
              >
                {item.unread && (
                  <span className="absolute start-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-7.5 text-center text-sm text-muted-foreground">
              No messages in {category}.
            </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 })}
            onRemove={() => remove(current.id)}
          />
        ) : (
          <p className="m-auto text-muted-foreground">No Message Selected</p>
        )}
      </SplitViewDetail>
    </SplitView>
  )
}
```

A mail inbox that sorts messages into Primary, Transactions, Updates and Promotions, switched with icon chips that show a dot when they hold unread mail. Selecting a message opens a reader with reply, star, archive and delete actions and an inline reply box. On mobile the list and reader show one at a time with a back button. Use it for notification centers, categorized inboxes or any feed that needs tabs.

## Installation

### CLI

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

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-categories
```

### 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), [Split View](https://ui.suiss.app/docs/patterns/split-view), [Textarea](https://ui.suiss.app/docs/components/textarea), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group).

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

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

import {
  ArchiveIcon,
  ArrowBendUpLeftIcon,
  BellIcon,
  CreditCardIcon,
  StarIcon,
  TagIcon,
  TrashIcon,
  UsersIcon,
} from "@phosphor-icons/react"
import * as React from "react"

import {
  SplitView,
  SplitViewDetail,
  SplitViewItem,
  SplitViewList,
} from "@/components/patterns/split-view"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

type Category = "Primary" | "Transactions" | "Updates" | "Promotions"

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

const initialMail: 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",
    hue: 0,
    category: "Transactions",
    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",
    hue: 30,
    category: "Primary",
    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",
    hue: 210,
    category: "Updates",
    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",
    hue: 150,
    category: "Primary",
    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",
    hue: 0,
    category: "Promotions",
    unread: false,
    starred: false,
  },
  {
    id: 5,
    from: "App Store",
    subject: "Your receipt",
    body: "Your Studio Pro subscription renewed. Total: $4.99.",
    time: "Monday",
    hue: 0,
    category: "Transactions",
    unread: false,
    starred: false,
  },
]

const categories: { value: Category; icon: React.ReactNode; tint: string }[] = [
  {
    value: "Primary",
    icon: (
      <UsersIcon className="size-4" />
    ),
    tint: "aria-pressed:bg-primary",
  },
  {
    value: "Transactions",
    icon: (
      <CreditCardIcon className="size-4" />
    ),
    tint: "aria-pressed:bg-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_40%)] dark:aria-pressed:bg-[color-mix(in_oklab,var(--color-green-500),black_40%)]",
  },
  {
    value: "Updates",
    icon: (
      <BellIcon className="size-4" />
    ),
    tint: "aria-pressed:bg-[color-mix(in_oklab,var(--color-purple-500),var(--foreground)_20%)] dark:aria-pressed:bg-[color-mix(in_oklab,var(--color-purple-500),black_30%)]",
  },
  {
    value: "Promotions",
    icon: (
      <TagIcon className="size-4" />
    ),
    tint: "aria-pressed:bg-[color-mix(in_oklab,var(--color-pink-500),var(--foreground)_20%)] dark:aria-pressed:bg-[color-mix(in_oklab,var(--color-pink-500),black_25%)]",
  },
]

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,
  onRemove,
}: {
  mail: Mail
  onStar: () => void
  onRemove: () => 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: onRemove,
    },
    {
      label: "Delete",
      icon: (
        <TrashIcon className="size-4.5" />
      ),
      onClick: onRemove,
    },
  ]

  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 InboxCategories() {
  const [mail, setMail] = React.useState(initialMail)
  const [category, setCategory] = React.useState<Category>("Primary")
  const [selected, setSelected] = React.useState<number | null>(1)
  const list = mail.filter((item) => item.category === category)
  const current = list.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)
    setSelected(rest.find((item) => item.category === category)?.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"
    >
      <SplitViewList title="Inbox" className="gap-3 md:px-2 md:pt-4">
        <p className="text-3xl font-bold tracking-tight max-md:hidden md:px-1.5">
          Inbox
        </p>
        <ToggleGroup
          value={[category]}
          onValueChange={(value: string[]) => {
            const next = value[0] as Category | undefined
            if (!next) return
            setCategory(next)
            setSelected(mail.find((item) => item.category === next)?.id ?? null)
          }}
          aria-label="Category"
          spacing={1.5}
          className="md:px-1.5"
        >
          {categories.map((item) => {
            const unread = mail.some(
              (entry) => entry.category === item.value && entry.unread
            )
            const pressed = category === item.value
            return (
              <ToggleGroupItem
                key={item.value}
                value={item.value}
                aria-label={item.value}
                className={`relative h-8.5 gap-1.5 bg-secondary px-2.75 font-semibold text-muted-foreground transition-[padding,background-color,color] duration-350 ease-[cubic-bezier(0.3,1.25,0.5,1)] hover:bg-secondary/80 aria-pressed:px-3.5 aria-pressed:text-white aria-pressed:hover:text-white ${item.tint}`}
              >
                {item.icon}
                {pressed && <span aria-hidden>{item.value}</span>}
                {!pressed && unread && (
                  <span className="absolute end-0.5 top-0.5 size-2 rounded-full bg-primary ring-2 ring-background">
                    <span className="sr-only">Unread</span>
                  </span>
                )}
              </ToggleGroupItem>
            )
          })}
        </ToggleGroup>
        <div key={category} className="flex flex-col gap-0.5">
          {list.length > 0 ? (
            list.map((item, index) => (
              <SplitViewItem
                key={item.id}
                isActive={item.id === selected}
                onClick={() => {
                  setSelected(item.id)
                  update(item.id, { unread: false })
                }}
                style={{ transitionDelay: `${index * 40}ms` }}
                className="group/mail relative py-2.5 ps-6 transition-[opacity,translate] duration-700 starting:translate-y-1.5 starting:opacity-0 motion-reduce:transition-none md:data-active:bg-primary md:data-active:text-primary-foreground"
              >
                {item.unread && (
                  <span className="absolute start-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-7.5 text-center text-sm text-muted-foreground">
              No messages in {category}.
            </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 })}
            onRemove={() => remove(current.id)}
          />
        ) : (
          <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 { InboxCategories } from "@/components/blocks/inbox-categories"
```

```tsx
<InboxCategories />
```

Replace `initialMail` with your messages, each with a sender, subject, body, time, avatar hue, category and unread and starred flags; `categories` sets each chip's icon and tint. Archive and Delete both just remove the message, and the reply form only clears on submit, so connect them to your mail API. The To: Jamie Rivera recipient line is hard-coded.

## Notes

The reply box receives focus when it opens, and unread dots include hidden Unread text for screen readers.

**React Native, SwiftUI, Jetpack Compose**

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

```tsx title="uniwind/components/blocks/inbox-categories.tsx"
import { cn } from "cn"
import {
  ArchiveIcon,
  ArrowBendUpLeftIcon,
  BellIcon,
  CreditCardIcon,
  type Icon as PhosphorIcon,
  StarIcon,
  TagIcon,
  TrashIcon,
  UsersIcon,
} 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,
} 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 { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"
import { Textarea } from "@/components/ui/textarea"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

type Category = "Primary" | "Transactions" | "Updates" | "Promotions"

type Mail = {
  id: number
  from: string
  subject: string
  body: string
  time: string
  tint: string | null
  category: Category
  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,
    category: "Transactions",
    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",
    category: "Primary",
    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",
    category: "Updates",
    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",
    category: "Primary",
    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,
    category: "Promotions",
    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,
    category: "Transactions",
    unread: false,
    starred: false,
  },
]

const CATEGORIES: { value: Category; icon: PhosphorIcon; tint: string }[] = [
  { value: "Primary", icon: UsersIcon, tint: "bg-primary" },
  { value: "Transactions", icon: CreditCardIcon, tint: "bg-green-500" },
  { value: "Updates", icon: BellIcon, tint: "bg-purple-500" },
  { value: "Promotions", icon: TagIcon, tint: "bg-pink-500" },
]

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

function SenderAvatar({ mail, className }: { mail: Mail; className?: string }) {
  return (
    <Avatar size="lg" className={className}>
      <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 InboxCategories({
  onCategoryChange,
  onReply,
  onStarChange,
  onArchive,
  onDelete,
}: {
  onCategoryChange?: (category: Category) => 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 [category, setCategory] = React.useState<Category>("Primary")
  const [selected, setSelected] = React.useState<number | null>(1)
  const [column, setColumn] = React.useState<SplitViewColumn>("list")
  const list = mail.filter((item) => item.category === category)
  const current = list.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.find((item) => item.category === category)?.id ?? null)
      setColumn("list")
    }
  }

  return (
    <Screen edges={["top", "right", "left"]}>
      <SplitView
        column={column}
        onColumnChange={setColumn}
        className={wide ? "m-4" : undefined}
      >
        <SplitViewList
          title="Inbox"
          className={wide ? "gap-3 px-2 pt-4" : "gap-3 px-4 pb-8"}
        >
          {wide ? (
            <Text role="heading" className="px-1.5 text-3xl font-bold">
              Inbox
            </Text>
          ) : null}
          <ToggleGroup
            value={[category]}
            onValueChange={(value) => {
              const next = value[0] as Category | undefined
              if (!next) return
              setCategory(next)
              setSelected(
                mail.find((item) => item.category === next)?.id ?? null
              )
              onCategoryChange?.(next)
            }}
            accessibilityLabel="Category"
            spacing={1.5}
            className={wide ? "px-1.5" : undefined}
          >
            {CATEGORIES.map((item) => {
              const unread = mail.some(
                (entry) => entry.category === item.value && entry.unread
              )
              const pressed = category === item.value
              return (
                <ToggleGroupItem
                  key={item.value}
                  value={item.value}
                  accessibilityLabel={item.value}
                  accessibilityHint={!pressed && unread ? "Unread" : undefined}
                  className={cn(
                    "relative h-8.5 gap-1.5 px-2.75",
                    pressed ? cn("px-3.5", item.tint) : "bg-secondary"
                  )}
                >
                  <Icon
                    as={item.icon}
                    weight="bold"
                    className={cn(
                      "size-4",
                      pressed
                        ? "text-primary-foreground"
                        : "text-muted-foreground"
                    )}
                  />
                  {pressed ? (
                    <Text className="font-semibold text-primary-foreground">
                      {item.value}
                    </Text>
                  ) : null}
                  {!pressed && unread ? (
                    <View className="absolute end-0 top-0 size-3 rounded-full border-2 border-background bg-primary" />
                  ) : null}
                </ToggleGroupItem>
              )
            })}
          </ToggleGroup>
          <View key={category} 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-7.5 text-center text-sm text-muted-foreground">
                {`No messages in ${category}.`}
              </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 { InboxCategories }
```

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

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

  struct Category {
    let value: String
    let icon: Image
    let tint: Color
  }

  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, category: "Transactions", 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, category: "Primary", 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, category: "Updates", 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, category: "Primary", 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, category: "Promotions", 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, category: "Transactions", unread: false, starred: false),
    ]
  }

  static var categories: [Category] {
    [
      Category(value: "Primary", icon: Ph.users.bold, tint: EsuissColor.primary),
      Category(value: "Transactions", icon: Ph.creditCard.bold, tint: Palette.green500),
      Category(value: "Updates", icon: Ph.bell.bold, tint: Palette.purple500),
      Category(value: "Promotions", icon: Ph.tag.bold, tint: Palette.pink500),
    ]
  }

  private var onCategoryChange: ((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 let replying: Bool
  private let swiped: Int?
  @State private var mail: [Mail]
  @State private var category: String
  @State private var selected: Int?
  @State private var column: SplitViewColumn
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(
    onCategoryChange: ((_ category: String) -> 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(category: "Primary")
    self.onCategoryChange = onCategoryChange
    self.onArchive = onArchive
    self.onDelete = onDelete
    self.onReply = onReply
    self.onStarChange = onStarChange
  }

  init(
    category: String = "Primary",
    selected: Int? = 1,
    column: SplitViewColumn = .list,
    replying: Bool = false,
    swiped: Int? = nil
  ) {
    _mail = State(initialValue: Self.initialMail)
    _category = State(initialValue: category)
    _selected = State(initialValue: selected)
    _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)
  }

  public var body: some View {
    let list = mail.filter { $0.category == category }
    let current = list.first { $0.id == selected }
    Screen {
      SplitView(column: $column) {
        SplitViewList("Inbox") {
          VStack(alignment: .leading, spacing: 12) {
            InboxCategoriesHeading()
            categoryBar
            VStack(spacing: 0) {
              if list.isEmpty {
                Text("No messages in \(category).")
                  .scaledFont(.sm)
                  .foregroundStyle(EsuissColor.mutedForeground)
                  .frame(maxWidth: .infinity)
                  .padding(30)
              }
              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 }
                  }) {
                    InboxCategoriesRow(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)))
              }
            }
            .id(category)
            .transition(.opacity.combined(with: .offset(y: 6)))
          }
        }
        SplitViewDetail {
          if let current {
            InboxCategoriesReader(
              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 var categoryBar: some View {
    HStack(spacing: 6) {
      ForEach(Self.categories, id: \.value) { item in
        let pressed = category == item.value
        let unread = mail.contains { $0.category == item.value && $0.unread }
        Button {
          select(item.value)
        } label: {
          HStack(spacing: 6) {
            item.icon.frame(width: 16, height: 16)
            if pressed {
              Text(LocalizedStringKey(item.value))
                .scaledFont(.sm, weight: .semibold)
                .lineLimit(1)
                .fixedSize()
                .transition(.opacity)
            }
          }
          .foregroundStyle(pressed ? EsuissColor.primaryForeground : EsuissColor.mutedForeground)
          .padding(.horizontal, pressed ? 14 : 11)
          .frame(height: 34)
          .background(pressed ? item.tint : EsuissColor.secondary, in: RoundedRectangle(cornerRadius: Radius.lg, style: .continuous))
          .overlay(alignment: .topTrailing) {
            if !pressed && unread {
              Circle()
                .fill(EsuissColor.primary)
                .frame(width: 8, height: 8)
                .padding(2)
                .background(EsuissColor.background, in: Circle())
                .offset(x: 1, y: -1)
                .accessibilityHidden(true)
            }
          }
          .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
        .accessibilityLabel(Text(LocalizedStringKey(item.value)))
        .accessibilityValue(unread && !pressed ? Text("Unread") : Text(verbatim: ""))
        .accessibilityAddTraits(pressed ? .isSelected : [])
      }
    }
    .padding(.top, 2)
    .hapticFeedback(.selection, trigger: category)
    .accessibilityElement(children: .contain)
    .accessibilityLabel(Text("Category"))
  }

  private func select(_ value: String) {
    guard value != category else { return }
    onCategoryChange?(value)
    withAnimation(reduceMotion ? nil : .spring(response: 0.35, dampingFraction: 0.75)) {
      category = value
      selected = mail.first { $0.category == value }?.id
    }
  }

  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 { $0.category == category }?.id
    }
  }
}

private struct InboxCategoriesHeading: View {
  @Environment(\.splitView) private var splitView

  var body: some View {
    if splitView?.wide ?? false {
      Text("Inbox")
        .scaledFont(.xl3, weight: .bold)
        .foregroundStyle(EsuissColor.foreground)
        .padding(.horizontal, 6)
        .accessibilityHidden(true)
    }
  }
}

private struct InboxCategoriesAvatar: View {
  let mail: InboxCategories.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 InboxCategoriesRow: View {
  let mail: InboxCategories.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 InboxCategoriesReader: View {
  let mail: InboxCategories.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: InboxCategories.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) {
          InboxCategoriesAvatar(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/InboxCategories.kt"
package com.esuiss.ui.blocks

import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.animateContentSize
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.border
import androidx.compose.foundation.interaction.MutableInteractionSource
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.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.draw.clip
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.Role
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.Bell
import com.adamglin.phosphoricons.bold.CreditCard
import com.adamglin.phosphoricons.bold.Tag
import com.adamglin.phosphoricons.bold.Trash as BoldTrash
import com.adamglin.phosphoricons.bold.Users
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.Star
import com.adamglin.phosphoricons.regular.Trash
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.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.Radius
import com.esuiss.ui.theme.Typography

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

private val CategoryPopEasing = CubicBezierEasing(0.3f, 1.25f, 0.5f, 1f)

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

private fun categoryMail() = listOf(
    CategoryMail(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, "Transactions", unread = true, starred = false),
    CategoryMail(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, "Primary", unread = true, starred = false),
    CategoryMail(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, "Updates", unread = true, starred = false),
    CategoryMail(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, "Primary", unread = false, starred = true),
    CategoryMail(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, "Promotions", unread = false, starred = false),
    CategoryMail(5, "App Store", "Your receipt", "Your Studio Pro subscription renewed. Total: \$4.99.", "Monday", 0, "Transactions", unread = false, starred = false),
)

private class MailCategory(val value: String, val icon: ImageVector, val tint: (Colors) -> Color)

private val MailCategories = listOf(
    MailCategory("Primary", PhosphorIcons.Bold.Users) { it.primary },
    MailCategory("Transactions", PhosphorIcons.Bold.CreditCard) {
        if (it.isDark) lerp(Palette.green500, Palette.black, 0.4f) else lerp(Palette.green500, it.foreground, 0.4f)
    },
    MailCategory("Updates", PhosphorIcons.Bold.Bell) {
        if (it.isDark) lerp(Palette.purple500, Palette.black, 0.3f) else lerp(Palette.purple500, it.foreground, 0.2f)
    },
    MailCategory("Promotions", PhosphorIcons.Bold.Tag) {
        if (it.isDark) lerp(Palette.pink500, Palette.black, 0.25f) else lerp(Palette.pink500, it.foreground, 0.2f)
    },
)

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 InboxCategories(
    modifier: Modifier = Modifier,
    onCategoryChange: (category: String) -> Unit = {},
    onArchive: (id: Int) -> Unit = {},
    onDelete: (id: Int) -> Unit = {},
    onReply: (id: Int, text: String) -> Unit = { _, _ -> },
    onStarChange: (id: Int, starred: Boolean) -> Unit = { _, _ -> },
) {
    val colors = EsuissTheme.colors
    val mail = remember { mutableStateListOf<CategoryMail>().apply { addAll(categoryMail()) } }
    var category by rememberSaveable { mutableStateOf("Primary") }
    var selected by rememberSaveable { mutableStateOf<Int?>(1) }
    var column by rememberSaveable { mutableStateOf(SplitViewColumn.List) }
    val list = mail.filter { it.category == category }
    val current = list.firstOrNull { it.id == selected }

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

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

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

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

    Screen(modifier) {
        SplitView(column = column, onColumnChange = { column = it }) {
            SplitViewList(title = "Inbox") {
                Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
                    Row(
                        modifier = Modifier.semantics { contentDescription = "Category" },
                        horizontalArrangement = Arrangement.spacedBy(6.dp),
                        verticalAlignment = Alignment.CenterVertically,
                    ) {
                        MailCategories.forEach { item ->
                            CategoryToggle(
                                category = item,
                                pressed = category == item.value,
                                unread = mail.any { it.category == item.value && it.unread },
                            ) {
                                onCategoryChange(item.value)
                                category = item.value
                                selected = mail.firstOrNull { it.category == item.value }?.id
                            }
                        }
                    }
                    key(category) {
                        Column {
                            if (list.isEmpty()) {
                                BasicText(
                                    "No messages in $category.",
                                    modifier = Modifier
                                        .fillMaxWidth()
                                        .padding(30.dp),
                                    style = Typography.sm.copy(color = colors.mutedForeground, textAlign = TextAlign.Center),
                                )
                            }
                            list.forEachIndexed { index, item ->
                                key(item.id) {
                                    CategoryAppear(index) {
                                        SwipeActions(
                                            leading = listOf(
                                                SwipeAction(
                                                    label = if (item.starred) "Unstar" else "Star",
                                                    onClick = { star(item) },
                                                    icon = PhosphorIcons.Fill.FillStar,
                                                ),
                                            ),
                                            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,
                                                ),
                                            ),
                                        ) {
                                            CategoryRow(item, active = item.id == selected) {
                                                selected = item.id
                                                item.unread = false
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
            SplitViewDetail {
                if (current != null) {
                    key(current.id) {
                        CategoryReader(
                            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 CategoryToggle(category: MailCategory, pressed: Boolean, unread: Boolean, onClick: () -> Unit) {
    val colors = EsuissTheme.colors
    val shape = RoundedCornerShape(Radius.lg)
    val fill by animateColorAsState(if (pressed) category.tint(colors) else colors.secondary, tween(350), label = "fill")
    val ink by animateColorAsState(if (pressed) Palette.white else colors.mutedForeground, tween(350), label = "ink")
    Box {
        Row(
            modifier = Modifier
                .height(34.dp)
                .clip(shape)
                .background(fill, shape)
                .selectable(
                    selected = pressed,
                    interactionSource = remember { MutableInteractionSource() },
                    indication = null,
                    role = Role.Tab,
                    onClick = onClick,
                )
                .semantics { contentDescription = category.value }
                .animateContentSize(tween(350, easing = CategoryPopEasing))
                .padding(horizontal = if (pressed) 14.dp else 11.dp),
            horizontalArrangement = Arrangement.spacedBy(6.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Icon(category.icon, contentDescription = null, size = 16.dp, tint = ink)
            if (pressed) {
                BasicText(category.value, style = Typography.sm.copy(color = ink, fontWeight = FontWeight.SemiBold), maxLines = 1)
            }
        }
        if (!pressed && unread) {
            Box(
                Modifier
                    .align(Alignment.TopEnd)
                    .offset(x = (-2).dp, y = 2.dp)
                    .size(10.dp)
                    .border(2.dp, colors.background, CircleShape)
                    .padding(2.dp)
                    .background(colors.primary, CircleShape)
                    .semantics { stateDescription = "Unread" },
            )
        }
    }
}

@Composable
private fun CategoryAppear(index: Int, content: @Composable () -> Unit) {
    val appear = remember { Animatable(0f) }
    LaunchedEffect(Unit) { appear.animateTo(1f, tween(700, delayMillis = index * 40, easing = CategoryRevealEasing)) }
    Box(
        Modifier.graphicsLayer {
            alpha = appear.value
            translationY = (1f - appear.value) * 6.dp.toPx()
        },
    ) {
        content()
    }
}

@Composable
private fun CategoryRow(mail: CategoryMail, 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 CategorySender(mail: CategoryMail, 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 CategoryReader(
    mail: CategoryMail,
    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 = CategoryRevealEasing)) }
    Column(Modifier.fillMaxWidth()) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .height(50.dp),
            horizontalArrangement = Arrangement.spacedBy(2.dp, Alignment.End),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            CategoryAction(PhosphorIcons.Regular.ArrowBendUpLeft, "Reply") { replying = true }
            CategoryAction(
                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,
            )
            CategoryAction(PhosphorIcons.Regular.Archive, "Archive", onClick = onArchive)
            CategoryAction(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) {
                CategorySender(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 CategoryAction(
    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 mail screen built on `SplitView`: the list column shows a `ToggleGroup` of four categories with unread dots, and rows with an `Avatar`, sender and preview; the detail column shows the selected message. On wide screens the list and reader sit side by side; on phones the reader opens as its own column. Swipe a row with `SwipeActions` to archive it, or swipe fully to delete. The reader has Reply, Star, Archive and Delete buttons, and Reply opens a `Textarea` to write a response.

## Installation

**React Native**

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

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-categories
```

**SwiftUI**

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

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add inbox-categories --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), [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), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group). 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), [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), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group), 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-categories.tsx`:

- `uniwind/components/blocks/inbox-categories.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), [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), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group), 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-categories.tsx`:

```tsx title="nativewind/components/blocks/inbox-categories.tsx"
import { cn } from "cn"
import {
  ArchiveIcon,
  ArrowBendUpLeftIcon,
  BellIcon,
  CreditCardIcon,
  type Icon as PhosphorIcon,
  StarIcon,
  TagIcon,
  TrashIcon,
  UsersIcon,
} 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,
} 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 { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"
import { Textarea } from "@/components/ui/textarea"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

type Category = "Primary" | "Transactions" | "Updates" | "Promotions"

type Mail = {
  id: number
  from: string
  subject: string
  body: string
  time: string
  tint: string | null
  category: Category
  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,
    category: "Transactions",
    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",
    category: "Primary",
    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",
    category: "Updates",
    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",
    category: "Primary",
    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,
    category: "Promotions",
    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,
    category: "Transactions",
    unread: false,
    starred: false,
  },
]

const CATEGORIES: { value: Category; icon: PhosphorIcon; tint: string }[] = [
  { value: "Primary", icon: UsersIcon, tint: "bg-primary" },
  { value: "Transactions", icon: CreditCardIcon, tint: "bg-green-500" },
  { value: "Updates", icon: BellIcon, tint: "bg-purple-500" },
  { value: "Promotions", icon: TagIcon, tint: "bg-pink-500" },
]

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

function SenderAvatar({ mail, className }: { mail: Mail; className?: string }) {
  return (
    <Avatar size="lg" className={className}>
      <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 InboxCategories({
  onCategoryChange,
  onReply,
  onStarChange,
  onArchive,
  onDelete,
}: {
  onCategoryChange?: (category: Category) => 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 [category, setCategory] = React.useState<Category>("Primary")
  const [selected, setSelected] = React.useState<number | null>(1)
  const [column, setColumn] = React.useState<SplitViewColumn>("list")
  const list = mail.filter((item) => item.category === category)
  const current = list.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.find((item) => item.category === category)?.id ?? null)
      setColumn("list")
    }
  }

  return (
    <Screen edges={["top", "right", "left"]}>
      <SplitView
        column={column}
        onColumnChange={setColumn}
        className={wide ? "m-4" : undefined}
      >
        <SplitViewList
          title="Inbox"
          className={wide ? "gap-3 px-2 pt-4" : "gap-3 px-4 pb-8"}
        >
          {wide ? (
            <Text role="heading" className="px-1.5 text-3xl font-bold">
              Inbox
            </Text>
          ) : null}
          <ToggleGroup
            value={[category]}
            onValueChange={(value) => {
              const next = value[0] as Category | undefined
              if (!next) return
              setCategory(next)
              setSelected(
                mail.find((item) => item.category === next)?.id ?? null
              )
              onCategoryChange?.(next)
            }}
            accessibilityLabel="Category"
            spacing={1.5}
            className={wide ? "px-1.5" : undefined}
          >
            {CATEGORIES.map((item) => {
              const unread = mail.some(
                (entry) => entry.category === item.value && entry.unread
              )
              const pressed = category === item.value
              return (
                <ToggleGroupItem
                  key={item.value}
                  value={item.value}
                  accessibilityLabel={item.value}
                  accessibilityHint={!pressed && unread ? "Unread" : undefined}
                  className={cn(
                    "relative h-[2.125rem] gap-1.5 px-[0.6875rem]",
                    pressed ? cn("px-3.5", item.tint) : "bg-secondary"
                  )}
                >
                  <Icon
                    as={item.icon}
                    weight="bold"
                    className={cn(
                      "size-4",
                      pressed
                        ? "text-primary-foreground"
                        : "text-muted-foreground"
                    )}
                  />
                  {pressed ? (
                    <Text className="font-semibold text-primary-foreground">
                      {item.value}
                    </Text>
                  ) : null}
                  {!pressed && unread ? (
                    <View className="absolute end-0 top-0 size-3 rounded-full border-2 border-background bg-primary" />
                  ) : null}
                </ToggleGroupItem>
              )
            })}
          </ToggleGroup>
          <View key={category} 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-[1.875rem] text-center text-sm text-muted-foreground">
                {`No messages in ${category}.`}
              </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 { InboxCategories }
```

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), [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), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group), 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-categories.tsx`:

```tsx title="stylesheet/components/blocks/inbox-categories.tsx"
import {
  ArchiveIcon,
  ArrowBendUpLeftIcon,
  BellIcon,
  CreditCardIcon,
  type Icon as PhosphorIcon,
  StarIcon,
  TagIcon,
  TrashIcon,
  UsersIcon,
} from "phosphor-react-native"
import * as React from "react"
import {
  StyleSheet,
  useWindowDimensions,
  View,
  type ViewStyle,
} 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,
} 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 { 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"

import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

type Tone = keyof ThemeColors | keyof typeof palette

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

type Tint = Tone

type Category = "Primary" | "Transactions" | "Updates" | "Promotions"

type Mail = {
  id: number
  from: string
  subject: string
  body: string
  time: string
  tint: Tint | null
  category: Category
  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,
    category: "Transactions",
    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",
    category: "Primary",
    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",
    category: "Updates",
    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",
    category: "Primary",
    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,
    category: "Promotions",
    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,
    category: "Transactions",
    unread: false,
    starred: false,
  },
]

const CATEGORIES: { value: Category; icon: PhosphorIcon; tint: Tint }[] = [
  { value: "Primary", icon: UsersIcon, tint: "primary" },
  { value: "Transactions", icon: CreditCardIcon, tint: "green500" },
  { value: "Updates", icon: BellIcon, tint: "purple500" },
  { value: "Promotions", icon: TagIcon, tint: "pink500" },
]

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 },
  listWide: { gap: 12, paddingHorizontal: 8, paddingTop: 16 },
  list: { gap: 12, paddingHorizontal: 16, paddingBottom: 32 },
  title: { ...fontSize["3xl"], paddingHorizontal: 6, fontWeight: "700" },
  toggles: { paddingHorizontal: 6 },
  toggle: { position: "relative", height: 34, gap: 6, paddingHorizontal: 11 },
  toggleOn: { paddingHorizontal: 14 },
  toggleText: { fontWeight: "600" },
  badge: {
    position: "absolute",
    end: 0,
    top: 0,
    width: 12,
    height: 12,
    borderRadius: radius.full,
    borderWidth: 2,
  },
  rows: { gap: 2 },
  empty: { ...fontSize.sm, padding: 30, textAlign: "center" },
  detail: { flexGrow: 1, paddingHorizontal: 16, paddingBottom: 32 },
  none: {
    flex: 1,
    alignItems: "center",
    justifyContent: "center",
    paddingVertical: 80,
  },
})

function SenderAvatar({ mail, style }: { mail: Mail; style?: ViewStyle }) {
  const { colors } = useTheme()
  return (
    <Avatar size="lg" style={style}>
      <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 InboxCategories({
  onCategoryChange,
  onReply,
  onStarChange,
  onArchive,
  onDelete,
}: {
  onCategoryChange?: (category: Category) => 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 [category, setCategory] = React.useState<Category>("Primary")
  const [selected, setSelected] = React.useState<number | null>(1)
  const [column, setColumn] = React.useState<SplitViewColumn>("list")
  const list = mail.filter((item) => item.category === category)
  const current = list.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.find((item) => item.category === category)?.id ?? null)
      setColumn("list")
    }
  }

  return (
    <Screen edges={["top", "right", "left"]}>
      <SplitView
        column={column}
        onColumnChange={setColumn}
        style={wide ? styles.wide : undefined}
      >
        <SplitViewList
          title="Inbox"
          style={wide ? styles.listWide : styles.list}
        >
          {wide ? (
            <Text role="heading" style={styles.title}>
              Inbox
            </Text>
          ) : null}
          <ToggleGroup
            value={[category]}
            onValueChange={(value) => {
              const next = value[0] as Category | undefined
              if (!next) return
              setCategory(next)
              setSelected(
                mail.find((item) => item.category === next)?.id ?? null
              )
              onCategoryChange?.(next)
            }}
            accessibilityLabel="Category"
            spacing={1.5}
            style={wide ? styles.toggles : undefined}
          >
            {CATEGORIES.map((item) => {
              const unread = mail.some(
                (entry) => entry.category === item.value && entry.unread
              )
              const pressed = category === item.value
              return (
                <ToggleGroupItem
                  key={item.value}
                  value={item.value}
                  accessibilityLabel={item.value}
                  accessibilityHint={!pressed && unread ? "Unread" : undefined}
                  style={[
                    styles.toggle,
                    pressed
                      ? [
                          styles.toggleOn,
                          { backgroundColor: tone(colors, item.tint) },
                        ]
                      : { backgroundColor: colors.secondary },
                  ]}
                >
                  <Icon
                    as={item.icon}
                    weight="bold"
                    size={16}
                    color={
                      pressed
                        ? colors.primaryForeground
                        : colors.mutedForeground
                    }
                  />
                  {pressed ? (
                    <Text
                      style={[
                        styles.toggleText,
                        { color: colors.primaryForeground },
                      ]}
                    >
                      {item.value}
                    </Text>
                  ) : null}
                  {!pressed && unread ? (
                    <View
                      style={[
                        styles.badge,
                        {
                          borderColor: colors.background,
                          backgroundColor: colors.primary,
                        },
                      ]}
                    />
                  ) : null}
                </ToggleGroupItem>
              )
            })}
          </ToggleGroup>
          <View key={category} 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 messages in ${category}.`}
              </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 { InboxCategories }
```

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/InboxCategories.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/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/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/InboxCategories.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/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 { InboxCategories } from "@/components/blocks/inbox-categories"
```

```tsx
<InboxCategories />
```

Replace `INITIAL_MAIL` with your messages and adjust `CATEGORIES` if you sort mail differently. Wire `onCategoryChange`, `onReply`, `onStarChange`, `onArchive` and `onDelete` to your mail service; the screen updates its own state either way.

