Inbox

A notification list with a reading pane for previewing the selected item.

4
Installation
$Terminal
npx shadcn@latest add https://sdk-components.thesqd.com/r/inbox-showcase.json
Usage
Import Inbox and pass an array of items. The reading pane is on by default.
TSImport
import { Inbox } from "@/components/blocks/inbox";
TSExample
import { Inbox } from "@/components/blocks/inbox";

const items = [
  {
    id: "n1",
    from: { name: "Priya Nair", email: "priya@churchmediasquad.com" },
    subject: "Easter Series concepts are ready for review",
    body: "Three directions are up in the concept board.",
    timestamp: new Date().toISOString(),
    unread: true,
  },
];

export function Example() {
  return <Inbox items={items} />;
}
Composition
Anatomy of the Inbox component.
Inbox                          (main component)
├── showPreview?               (boolean — default true; false renders the list alone)
├── multiSelect?               (boolean — default true; shift/cmd-click + floating bulk toolbar)
├── showSearch?                (boolean — default true)
├── searchPlaceholder?         (string — default "Search notifications")
├── showUnreadFilter?          (boolean — default true; "Unread only" entry in the header menu)
├── unreadOnlyLabel?           (string — default "Unread only")
├── onMarkAllAsRead?           (() => void — adds "Mark all as read" to the header menu)
├── markAllAsReadLabel?        (string — default "Mark all as read")
├── selectedId?                (string | null — controlled selection)
├── defaultSelectedId?         (string | null — uncontrolled; nothing is selected by default)
├── onSelect?                  ((item: InboxItem) => void)
├── onToggleRead?              ((item) => void — adds "Mark as read"/"unread" to the row menu)
├── onToggleStar?              ((item) => void — renders the star button on the pane toolbar)
├── onArchive?                 ((item) => void — renders the archive button)
├── onDelete?                  ((item) => void — renders the delete button)
├── previewActions?            (ReactNode — extra nodes on the pane toolbar)
├── onPreviewClose?            (() => void — fires on the pane's X; clears selection otherwise)
├── groupByDay?                (boolean — default true; Today / Yesterday / Last 7 days / … headings)
├── titleWidth?                (number — px, default 280; the title column)
├── projectWidth?              (number — px, default 160; the project column)
├── kindWidth?                 (number — px, default 110; the event-kind column)
├── showFilters?               (boolean — default true; the Filters button)
├── filterFields?              (FilterField[] — defaults to status, sender, title, read, starred, attachments)
├── filterValue?               (FilterGroup — controlled filter tree)
├── onFilterChange?            ((value: FilterGroup) => void)
├── items                      (InboxItem[])
│   ├── id                     (string — unique key)
│   ├── from                   (InboxSender)
│   │   ├── name               (string — drives the avatar initials + colour)
│   │   ├── email?             (string — shown in the reading pane)
│   │   ├── avatar?            (string — image URL)
│   │   └── initials?          (string — initials override)
│   ├── project?               (string — what it is about; its own left column)
│   ├── subject?               (string — the row's title column, bold in the pane)
│   ├── body                   (ReactNode — rendered in full in the reading pane)
│   ├── preview?               (ReactNode — the row's one truncated line; defaults to body)
│   ├── timestamp              (ISO string | Date)
│   ├── status?                (string | InboxStatus — the row's leading dot)
│   │   ├── label              (string — tooltip + screen-reader text)
│   │   ├── color?             (BadgeColor | `#rrggbb` — the dot's swatch; default "muted")
│   │   └── icon?              (ReactNode — a glyph instead of the dot; tint it yourself)
│   ├── unread?                (boolean — unread dot + bold treatment)
│   ├── starred?               (boolean — fills the star on the pane toolbar)
│   ├── badge?                 (ReactNode — pill after the subject, READING PANE only)
│   ├── statusChange?          ({ from, to } — renders "changed status from [from] → [to]")
│   ├── upload?                ({ project } — renders "uploaded N files to [project]")
│   └── attachments?           (InboxAttachment[] — { id, name, size? })
├── emptyState?                (ReactNode — replaces the list empty state)
├── card?                      (boolean — default true; wraps in the card surface)
├── height?                    (string | number — default "28rem")
├── listWidth?                 (number — px, default 352; starting width when resizable)
├── resizable?                 (boolean — default true; drag the divider between the panes)
├── minListWidth?              (number — px, default 240)
├── maxListWidth?              (number — px, default 560)
└── className?                 (string)
With preview
The default. The header is a single row — a search field, the unread count, and an overflow menu holding "Unread only" and "Mark all as read". Selecting a row clears its unread dot and renders it in the reading pane, with star / archive / delete on the pane toolbar. Right-clicking a row opens a context menu with mark read/unread, star, archive, and delete — it lists only the actions whose handlers you passed. Shift-click a second row to select a range (cmd/ctrl-click toggles one) and a floating bulk-action toolbar slides in. On mobile the two panes swap instead of sitting side by side.
4
List only
`showPreview={false}` drops the reading pane and lets the list fill the width — use it in a narrow column or when rows link somewhere else.
4
Flat
`card={false}` removes the card surface so the inbox can sit inside a panel you already own.
4
No header
`showSearch={false}` with `showUnreadFilter={false}` and no `onMarkAllAsRead` leaves nothing to put in the header, so it is dropped entirely and the list starts at the top edge.
Empty
With no items the list's empty state owns the whole card — there is no reading pane until something is selected. Override it with `emptyState`.

Inbox zero

New notifications will show up here.

API Reference
Props for the Inbox component and its item / sender / attachment types.

Inbox

PropTypeDefaultDescription
itemsInboxItem[]—The notifications to list, rendered in the order given. Each item's `status` draws the leading dot; `subject` is the title column and `body` the truncated preview beside it.
showPreviewbooleantrueAllow the reading pane at all. It only appears once a row is picked — the inbox opens as a full-width list, the way the file browser opens as a full-width tree — and the pane's X closes it again. Pass false to keep it a list whatever gets selected. On mobile the panes swap rather than sitting side by side.
groupByDaybooleantrueBucket the rows under "Today" / "Yesterday" / "Last 7 days" / "Last 30 days" / "Older" headings, each bucket in its own card. Items are not re-sorted — a bucket appears where its first item does, so the order you passed is the order shown.
titleWidthnumber280Width of the title column in px. Fixed, so the body previews beside it line up down the list the way the file browser's meta columns do.
projectWidthnumber160Width of the `project` column in px — what each notification is ABOUT, drawn muted at the left of the row before the subject. Only rows carrying `project` draw it.
kindWidthnumber110Width of the event-kind badge column in px. `kind` is the same word the filter bar's Event field offers, as a badge between the subject and the preview, so a reader can see what they would filter by without opening the filter. The badge is the width of its own words; this is the column they line up in. Nothing truncates — pass a short `kind` (two words) rather than widening the column.
showFiltersbooleantrueThe Filters button in the toolbar — the SDK's shared filter builder, evaluated over the items.
filterFieldsFilterField[]—What the builder can filter on. Defaults to status, sender, title, read state, starred and attachments; the status and sender vocabularies are read off the items you passed.
filterValueFilterGroup—Controlled filter tree. Pair with `onFilterChange`.
onFilterChange(value: FilterGroup) => void—Fires with the built tree whenever it changes.
multiSelectbooleantrueAllow selecting several rows at once — shift-click for a range (across the visible order, so a filtered-out row is never swept in), cmd/ctrl-click to toggle one. While a selection is live an SDK floating Toolbar slides in with the bulk actions. It reuses your per-item handlers, so it only offers the actions you wired up and stays hidden if none are.
selectedIdstring | null—Controlled selection. When set, the component stops tracking selection internally — pair it with `onSelect`.
defaultSelectedIdstring | null—Uncontrolled initial selection. Nothing is selected by default — the list opens full width and the reading pane arrives when a row is picked.
onSelect(item: InboxItem) => void—Fires when a row is clicked. Flip the item's `unread` flag here to clear its dot.
showSearchbooleantrueShow the search input. It matches against sender name, email, subject, status-change labels, upload project names, and string bodies. With this off and no menu entries, the header is dropped entirely.
searchPlaceholderstring"Search notifications"Placeholder for the search input.
showUnreadFilterbooleantrueOffer the "Unread only" toggle in the header overflow menu. A check mark marks it active.
unreadOnlyLabelstring"Unread only"Label for the unread-only filter (menu entry or toolbar toggle).
unreadFilterPlacement"menu" | "toolbar""menu"Where "Unread only" lives: an entry in the header's ⋯ menu, or a quick toggle in the header itself carrying the unread count.
onMarkAllAsRead() => void—Adds a "Mark all as read" entry to the header overflow menu when provided. Disabled while the unread count is 0.
markAllAsReadLabelstring"Mark all as read"Label for the mark-all action.
onToggleRead(item: InboxItem) => void—Toggles a single item's read state. Adds a "Mark as read" / "Mark as unread" entry to the row's right-click menu.
onToggleStar(item: InboxItem) => void—Renders a star toggle on the reading-pane toolbar, and a Star / Remove star entry on the row's right-click menu. The filled state reads from the item's `starred` field.
onArchive(item: InboxItem) => void—Renders an archive button on the reading-pane toolbar and an Archive entry on the row's right-click menu.
onDelete(item: InboxItem) => void—Renders a delete button on the reading-pane toolbar and a red Delete entry on the row's right-click menu.
previewActionsReactNode—Extra nodes on the right of the reading-pane toolbar (reply, forward, a "View project" button…). They share one right-hand group with the close X, so they always sit beside it rather than floating mid-toolbar.
emptyStateReactNode—Replaces the default list empty state (which distinguishes inbox-zero from no-search-matches).
onPreviewClose() => void—Fires when the X on the reading-pane toolbar is clicked. Without it the inbox clears its own selection, which is what closes the pane; a controlled caller must clear `selectedId` here or the pane will not close.
cardbooleantrueWrap in the shared `card-surface`. Pass false to embed the inbox in a panel you already own.
heightstring | number"28rem"Height of the scrolling panes. A number is treated as pixels. Both panes carry `scroll-fade`.
listWidthnumber352Width of the list pane in px at `md` and up, when the reading pane is shown. The starting width when `resizable`.
resizablebooleantrueDrag the divider between the panes to resize them. Arrow keys nudge it (16px, 48px with Shift), Home and a double-click reset it to `listWidth`.
minListWidthnumber240Floor for the dragged list width, in px.
maxListWidthnumber560Ceiling for the dragged list width, in px. Capped further so the reading pane keeps at least 260px.
factsWidthnumber280Starting width of the reading pane's facts column, in px. Its left edge drags.
minFactsWidthnumber240Floor for the dragged facts column, in px.
maxFactsWidthnumber560Ceiling for the dragged facts column, in px.
classNamestring—Override or extend the wrapper classes.

InboxItem

PropTypeDefaultDescription
idstring—Unique key and selection handle.
fromInboxSender—Sender — drives the avatar and the reading-pane byline.
subjectstring—Bold headline of the row and the reading pane.
bodyReactNode—Body copy, rendered in full in the reading pane — as rich as you like, since the pane scrolls. Only string bodies are searchable.
previewReactNode—The single truncated line the ROW shows. Defaults to `body`, which is right when `body` is text; pass it whenever `body` is anything a 36px row can't hold (a table of labelled rows, several paragraphs) so the row gets a sentence and the pane keeps the full thing.
timestampstring | Date—Relative in the list ("8m", "3h", then "Mar 04"); absolute in the reading pane.
unreadbooleanfalseBolds the row's title and keeps the whole row at full contrast; read rows grey out entire, from title through body preview to stamp. No separate dot — a greyed-out row already says it. Counts toward the header badge.
statusstring | InboxStatus—The state of the project or deliverable the notification is about, drawn as the leading dot on the row. A bare string is shorthand for `{ label }`; `color` takes a Badge token or a raw `#rrggbb` for statuses whose colours are owned elsewhere (ClickUp, Asana…). Falls back to `statusChange.to` when that is all you passed, and to a hollow ring when there is no status at all, so the column stays a column.
starredbooleanfalseFills the star on the reading-pane toolbar.
badgeReactNode—Pill rendered after the subject in the READING PANE. Not on the row — a row's state is its leading `status` dot, and a second pill saying the same thing eats the body preview's width.
statusChange{ from: string; to: string }—Status-change notification. Renders "changed status from [from] → [to]" as a muted pill, an arrow, and a brand pill — in both the row and the reading pane. Both labels are searchable.
upload{ project: string }—File-upload notification. Renders "uploaded N files to [project]" — N is read from `attachments`, so you never restate the count. Independent of `statusChange`, so an item can carry both. The project name is searchable.
attachmentsInboxAttachment[]—Attachment list. A paperclip appears on the row; the files render as cards in the reading pane.
leadReactNode—One sentence under the subject saying what the item means for the reader ("Easter Series is waiting on you."). Reading pane only.
actionsInboxAction[]—The next step, as buttons in the pane body: `{ label, href?, onClick?, variant?: "primary" | "secondary", icon? }`. The first `primary` is filled, the rest outlined. Put the notification's own action here, not in `previewActions`, so it is the obvious thing to do.
stepsInboxStep[]—Where the thing the notification is about has got to: `{ label, hint?, hintIcon?, state: "done" | "current" | "upcoming" }`, drawn in the facts column, under the facts, as the read-only vertical chips `Stepper`, each hint on its stop's tooltip and the current stop's hint shown under them as a warning `Alert` — so give a step a hint only when someone has to act (e.g. "Waiting on your feedback"). Omit for items with no stages.
stepsTitlestring"Where it is"The track's heading.
factsInboxFact[]—Labelled facts — `{ label, value, href?, onClick? }` — drawn as a side column beside the body when the pane is wide enough (a container query on the pane, not the screen) and under it when narrow. Its left edge drags to resize (arrow keys too; double-click resets); see `factsWidth`.

InboxSender

PropTypeDefaultDescription
namestring—Display name — drives the avatar initials and its deterministic colour.
emailstring—Shown under the name in the reading pane.
avatarstring—Avatar image URL.
faceReactNode—A node drawn in place of the avatar in the row and the reading pane — a brand mark or a live character. Size it to the 24px slot.
initialsstring—Explicit initials override.

InboxAttachment

PropTypeDefaultDescription
idstring—Unique key.
namestring—File name.
sizenumber—Size in bytes — rendered as B / KB / MB.