# Inbox

> A message list where archive and delete collapse the row in place with an Undo, and a reading pane whose toolbar archives on a second click and deletes on a hold.

A shadcn/ui block from the [@sureui](https://sureui.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/sureui/inbox-01).

- Page, with a live preview: https://shadcn.exchange/r/sureui/inbox-01
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/sureui/inbox-01-light-5da50b2fa93b7f95.png
- Registry item: https://sureui.com/r/inbox-01.json
- Categories: block, mail
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://sureui.com/r/inbox-01.json
```

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

## Dependencies

- npm packages: `lucide-react`
- Registry items: `@sureui/confirm-button`, `@sureui/undoable`, `button`, `card`

## Files

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

```tsx
"use client"

import * as React from "react"
import { ArchiveIcon, ArrowLeftIcon, CheckIcon, Trash2Icon } from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Card, CardTitle } from "@/components/ui/card"
import { ConfirmButton } from "@/components/ui/sureui/confirm-button"
import { Undoable } from "@/components/ui/sureui/undoable"

type Message = {
  id: string
  from: string
  subject: string
  body: string
  time: string
  unread?: boolean
}

type InboxProps = {
  initialMessages?: Message[]
  className?: string
}

const sampleMessages: Message[] = [
  {
    id: "msg_1",
    from: "Priya Shah",
    subject: "Launch checklist",
    body: "Final checklist for Thursday. Anything missing before we ship?",
    time: "9:41",
    unread: true,
  },
  {
    id: "msg_2",
    from: "Marcus Chen",
    subject: "Re: Q3 numbers",
    body: "Can you check the churn figures before Monday?",
    time: "8:15",
    unread: true,
  },
  {
    id: "msg_3",
    from: "Lena Fischer",
    subject: "Design review moved to Friday",
    body: "It clashes with the all-hands, so it's now Friday at 2pm.",
    time: "Yesterday",
  },
  {
    id: "msg_4",
    from: "Omar Farouk",
    subject: "Offsite venue options",
    body: "Three venues are free the week of the 20th. Vote by Wednesday.",
    time: "Mon",
  },
]

function request() {
  return Promise.resolve()
}

function Inbox({ initialMessages = sampleMessages, className }: InboxProps) {
  const [messages, setMessages] = React.useState(initialMessages)
  const [openId, setOpenId] = React.useState(initialMessages[0]?.id ?? null)
  const [reading, setReading] = React.useState(false)
  const [leaving, setLeaving] = React.useState<Record<string, string>>({})
  const rows = React.useRef(new Map<string, HTMLButtonElement>())
  const readerRef = React.useRef<HTMLDivElement>(null)
  const titleRef = React.useRef<HTMLHeadingElement>(null)

  const open = messages.find((message) => message.id === openId)

  function neighbour(id: string) {
    const staying = messages.filter(
      (message) => message.id === id || !leaving[message.id]
    )
    const index = staying.findIndex((message) => message.id === id)
    return (staying[index + 1] ?? staying[index - 1])?.id ?? null
  }

  function focusRow(id: string | null) {
    requestAnimationFrame(() => {
      const reader = readerRef.current
      const inReader = reader?.contains(document.activeElement)
      if (inReader && reader?.checkVisibility?.() !== false) return
      if (id) rows.current.get(id)?.focus()
    })
  }

  function read(id: string) {
    setOpenId(id)
    setReading(true)
    setMessages((prev) =>
      prev.map((message) =>
        message.id === id ? { ...message, unread: false } : message
      )
    )
    requestAnimationFrame(() => {
      if (readerRef.current?.checkVisibility?.() === false) return
      if (rows.current.get(id)?.checkVisibility?.() === false) {
        titleRef.current?.focus()
      }
    })
  }

  function leave(id: string, action = "") {
    setLeaving((prev) => ({ ...prev, [id]: action }))
    if (action && openId === id) setOpenId(neighbour(id))
  }

  function drop(id: string) {
    setMessages((prev) => prev.filter((message) => message.id !== id))
  }

  async function finish(id: string) {
    await request()
    const next = neighbour(id)
    drop(id)
    setOpenId(next)
    setReading(false)
    focusRow(next)
  }

  return (
    <Card className={cn("@container/inbox w-full py-0 text-sm", className)}>
      <div className="grid min-h-80 @2xl/inbox:grid-cols-[2fr_3fr]">
        <section
          aria-label="Inbox"
          className={cn(
            "min-w-0 @2xl/inbox:border-r",
            reading && "hidden @2xl/inbox:block"
          )}
        >
          <div className="flex h-12 items-center border-b px-4">
            <CardTitle>Inbox</CardTitle>
          </div>
          {messages.length === 0 && (
            <p className="p-4 text-muted-foreground">No messages</p>
          )}
          <ul className="divide-y">
            {messages.map((message) => (
              <Undoable
                key={message.id}
                render={
                  <li
                    data-open={message.id === openId || undefined}
                    className="group/row relative has-data-[slot=undoable-strip]:flex has-data-[slot=undoable-strip]:items-center has-data-[slot=undoable-strip]:gap-2 has-data-[slot=undoable-strip]:px-4 has-data-[slot=undoable-strip]:py-2 @2xl/inbox:data-open:bg-muted/60"
                  />
                }
                label={`${leaving[message.id]}: ${message.subject}`}
                onConfirm={() => drop(message.id)}
                onCancel={() => leave(message.id)}
              >
                {({ remove }) => (
                  <>
                    <button
                      ref={(node) => {
                        if (node) rows.current.set(message.id, node)
                        else rows.current.delete(message.id)
                      }}
                      type="button"
                      aria-current={message.id === openId || undefined}
                      className="grid w-full gap-1 px-4 py-3 text-left outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset"
                      onClick={() => read(message.id)}
                    >
                      <span className="flex items-baseline justify-between gap-3">
                        <span
                          className={cn(
                            "truncate",
                            message.unread && "font-semibold"
                          )}
                        >
                          {message.unread && (
                            <span className="sr-only">Unread, </span>
                          )}
                          {message.from}
                        </span>
                        <span className="shrink-0 text-xs text-muted-foreground">
                          {message.time}
                        </span>
                      </span>
                      <span className="truncate pr-16 text-muted-foreground">
                        {message.subject}
                      </span>
                    </button>
                    <div className="absolute right-2.5 bottom-2 flex gap-1 opacity-0 group-hover/row:opacity-100 group-has-[:focus-visible]/row:opacity-100 pointer-coarse:opacity-100">
                      <Button
                        variant="ghost"
                        size="icon-sm"
                        aria-label={`Archive ${message.subject}`}
                        title="Archive"
                        onClick={() => {
                          leave(message.id, "Archived")
                          remove()
                        }}
                      >
                        <ArchiveIcon />
                      </Button>
                      <Button
                        variant="ghost"
                        size="icon-sm"
                        aria-label={`Delete ${message.subject}`}
                        title="Delete"
                        onClick={() => {
                          leave(message.id, "Deleted")
                          remove()
                        }}
                      >
                        <Trash2Icon />
                      </Button>
                    </div>
                  </>
                )}
              </Undoable>
            ))}
          </ul>
        </section>
        <div
          ref={readerRef}
          role="region"
          aria-label="Message"
          className={cn("min-w-0", !reading && "hidden @2xl/inbox:block")}
        >
          <div className="flex h-12 items-center gap-1 border-b px-2">
            <Button
              variant="ghost"
              size="icon-sm"
              aria-label="Back to inbox"
              className="@2xl/inbox:hidden"
              onClick={() => {
                setReading(false)
                focusRow(openId)
              }}
            >
              <ArrowLeftIcon />
            </Button>
            {open && (
              <>
                <span className="hidden px-2 text-xs text-muted-foreground @2xl/inbox:inline">
                  {messages.indexOf(open) + 1} of {messages.length}
                </span>
                <div className="ml-auto flex gap-1">
                  <ConfirmButton
                    gesture="click-again"
                    variant="ghost"
                    size="icon-sm"
                    aria-label="Archive"
                    title="Archive"
                    confirmLabel={<CheckIcon />}
                    announcements={{ armed: "Click again to archive" }}
                    onConfirm={() => finish(open.id)}
                  >
                    <ArchiveIcon />
                  </ConfirmButton>
                  <ConfirmButton
                    gesture="hold"
                    variant="ghost"
                    size="icon-sm"
                    aria-label="Hold to delete"
                    title="Hold to delete"
                    confirmLabel={<CheckIcon />}
                    onConfirm={() => finish(open.id)}
                  >
                    <Trash2Icon />
                  </ConfirmButton>
                </div>
              </>
            )}
          </div>
          {open ? (
            <article className="grid gap-4 p-4 sm:p-6">
              <div className="grid gap-1">
                <h3
                  ref={titleRef}
                  tabIndex={-1}
                  className="font-heading text-lg font-medium outline-none"
                >
                  {open.subject}
                </h3>
                <p className="text-muted-foreground">
                  {open.from} · {open.time}
                </p>
              </div>
              <p className="leading-relaxed text-pretty">{open.body}</p>
            </article>
          ) : (
            <p className="p-4 text-muted-foreground">No message open</p>
          )}
        </div>
      </div>
    </Card>
  )
}

export { Inbox, type InboxProps, type Message }
```
