# Inbox Categories

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

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

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

## Install

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

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

## Dependencies

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

## Files

### `bases/radix/components/blocks/inbox-categories.tsx`

```tsx
"use client"

import * as React from "react"

import { IconPlaceholder } from "@/app/(create)/components/icon-placeholder"
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: (
      <IconPlaceholder
        lucide="UsersIcon"
        tabler="IconUsers"
        hugeicons="UserGroupIcon"
        phosphor="UsersIcon"
        remixicon="RiGroupLine"
        className="size-4"
      />
    ),
    tint: "data-[state=on]:bg-primary",
  },
  {
    value: "Transactions",
    icon: (
      <IconPlaceholder
        lucide="CreditCardIcon"
        tabler="IconCreditCard"
        hugeicons="CreditCardIcon"
        phosphor="CreditCardIcon"
        remixicon="RiBankCardLine"
        className="size-4"
      />
    ),
    tint: "data-[state=on]:bg-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_40%)] dark:data-[state=on]:bg-[color-mix(in_oklab,var(--color-green-500),black_40%)]",
  },
  {
    value: "Updates",
    icon: (
      <IconPlaceholder
        lucide="BellIcon"
        tabler="IconBell"
        hugeicons="Notification03Icon"
        phosphor="BellIcon"
        remixicon="RiNotificationLine"
        className="size-4"
      />
    ),
    tint: "data-[state=on]:bg-[color-mix(in_oklab,var(--color-purple-500),var(--foreground)_20%)] dark:data-[state=on]:bg-[color-mix(in_oklab,var(--color-purple-500),black_30%)]",
  },
  {
    value: "Promotions",
    icon: (
      <IconPlaceholder
        lucide="TagIcon"
        tabler="IconTag"
        hugeicons="Tag01Icon"
        phosphor="TagIcon"
        remixicon="RiPriceTag3Line"
        className="size-4"
      />
    ),
    tint: "data-[state=on]:bg-[color-mix(in_oklab,var(--color-pink-500),var(--foreground)_20%)] dark:data-[state=on]: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: (
        <IconPlaceholder
          lucide="ReplyIcon"
          tabler="IconArrowBackUp"
          hugeicons="ArrowTurnBackwardIcon"
          phosphor="ArrowBendUpLeftIcon"
          remixicon="RiReplyLine"
          className="size-4.5"
        />
      ),
      onClick: () => setReplying(true),
    },
    {
      label: mail.starred ? "Unstar" : "Star",
      icon: (
        <IconPlaceholder
          lucide="StarIcon"
          tabler="IconStar"
          hugeicons="StarIcon"
          phosphor="StarIcon"
          remixicon="RiStarLine"
          className="size-4.5"
        />
      ),
      onClick: onStar,
      active: mail.starred,
    },
    {
      label: "Archive",
      icon: (
        <IconPlaceholder
          lucide="ArchiveIcon"
          tabler="IconArchive"
          hugeicons="Archive02Icon"
          phosphor="ArchiveIcon"
          remixicon="RiArchiveLine"
          className="size-4.5"
        />
      ),
      onClick: onRemove,
    },
    {
      label: "Delete",
      icon: (
        <IconPlaceholder
          lucide="TrashIcon"
          tabler="IconTrash"
          hugeicons="Delete02Icon"
          phosphor="TrashIcon"
          remixicon="RiDeleteBinLine"
          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
          type="single"
          value={category}
          onValueChange={(value: string) => {
            const next = value as Category | ""
            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 data-[state=on]:px-3.5 data-[state=on]:text-white data-[state=on]: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 && (
                    <IconPlaceholder
                      lucide="StarIcon"
                      tabler="IconStar"
                      hugeicons="StarIcon"
                      phosphor="StarIcon"
                      remixicon="RiStarLine"
                      aria-label="Starred"
                      className="size-3 text-[color-mix(in_oklab,var(--color-orange-500),var(--foreground)_45%)] md:group-data-active/mail:text-primary-foreground dark:text-orange-500 fill-current"
                    />
                  )}
                  <span className="text-xs text-muted-foreground md:group-data-active/mail:text-primary-foreground">
                    {item.time}
                  </span>
                </span>
                <span className="truncate text-sm">{item.subject}</span>
                <span className="line-clamp-2 text-xs leading-snug text-muted-foreground md:group-data-active/mail:text-primary-foreground">
                  {item.body}
                </span>
              </SplitViewItem>
            ))
          ) : (
            <p className="p-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>
  )
}
```
