# An email client on shadcn/ui: lazy TipTap, autosave, and six traps in Radix, React and the DOM

> How Mailroom builds a three-pane email client on shadcn/ui: one store per inbox, TipTap loaded in idle time, autosave that stops on send, and six traps from Radix, ProseMirror and the DOM.

Behind the build, by [Ramin Valadan](https://upfork.dev/developers/ramin-valadan), about [Mailroom — Email Client & Inbox UI for React, shadcn/ui and Tailwind](https://upfork.dev/products/email-client-inbox-react-shadcn) (Component, comes with the Pro plan). Published 2026-10-26. 6 min read. Written with AI: Claude Opus 5.5, through Claude.

## What it is

Mailroom is an email client component for React and shadcn/ui: three resizable panes (folders, the list, the conversation), threads, a rich-text Compose window with recipient chips and attachments, an inline reply, snooze, labels, multi-select with bulk actions and Undo, keyboard shortcuts, and layouts for tablets and phones, light and dark from shadcn's own tokens.

[Live preview of Mailroom — Email Client & Inbox UI for React, shadcn/ui and Tailwind](https://upfork.dev/products/email-client-inbox-react-shadcn)

This article is for anyone, person or AI, building a complex app-like component on shadcn/ui: how the state is held, how a rich-text editor stays out of the first load, how autosave behaves around an animated window, and six small traps in Radix, ProseMirror, React and the DOM that each cost a real bug, with the fix for each.

## One store per inbox

Every `<Mail>` creates its own zustand store, so two inboxes on one page never share a selection, and the parts of the UI read it through a context. Things that are not state but one-off requests between parts (a shortcut asking the toolbar to open its label picker, "reply" from a key press) travel as events on the same context:

```ts
export type MailEvent =
  | { type: "focus-search" }
  | { type: "open-labels" }
  | { type: "open-snooze" }
  | { type: "reply"; mode: ReplyMode }
  | { type: "open-folders" }
```

The layout follows the component's own width, not the window's: three panes from 900 px, two from 640 px, one below, so the same component works full screen or inside a dashboard column. On the one-pane layout every button grows to a 40 px tap target and fields get 16 px text, so iOS doesn't zoom in when one is focused:

`components/mail/mail.tsx`, lines 118–130:

```tsx
      <TooltipProvider delayDuration={300} skipDelayDuration={200}>
        <div
          ref={root}
          data-layout={layout ?? undefined}
          className={cn(
            "relative isolate flex h-full min-h-[460px] w-full min-w-0 overflow-hidden bg-background text-foreground",
            layout !== "one" && "rounded-xl border shadow-xs",
            // Phones: every button is at least a 40 px tap target.
            "data-[layout=one]:[&_button:is([data-slot=button],[data-slot=tooltip-trigger]):not([data-hit])]:min-h-10 data-[layout=one]:[&_button:is([data-slot=button],[data-slot=tooltip-trigger]):not([data-hit])]:min-w-10",
            // …and 16 px text in fields, so iOS doesn't zoom in when one is focused.
            "data-[layout=one]:[&_.ProseMirror]:text-base",
            className,
          )}
```

That selector looks for two `data-slot` values, not one, and that is the first trap:

> **Lesson: Radix's asChild Slot lets the outer part's data-slot win: a shadcn Button inside TooltipTrigger reads tooltip-trigger** (Not in the docs; learned by the AI (Claude Opus 5.5); radix-ui@1)
> **I assumed:** every shadcn Button carries `data-slot="button"`, so `[data-slot=button]` could size all of them.
> **What happened:** a Button wrapped in `<TooltipTrigger asChild>` renders with `data-slot="tooltip-trigger"`: Radix's `Slot` merges the trigger's props onto its child, and the trigger's `data-slot` replaces the Button's. A rule on `[data-slot=button]` missed every button that has a tooltip.
> **The fix:** select both, `button:is([data-slot=button],[data-slot=tooltip-trigger])`, with `data-hit` as an opt-out for buttons that are meant to stay small.
> **Evidence:** commit 2e3b352; mail.tsx lines 125–126.

## The editor stays out of the first load

The Compose window and the inline reply use TipTap. The editor is 127 kB gzipped on its own, so it is a separate chunk, fetched while the page is idle, and mounted in the same render if it has already arrived:

`components/mail/editor.tsx`, lines 9–38:

```tsx
type RichTextModule = typeof import("./rich-text")
let loaded: RichTextModule | null = null
const load = () =>
  import("./rich-text").then((module) => {
    loaded = module
    return module
  })
const RichText = React.lazy(load)

let preloaded = false
/** Fetches the editor in the background, so opening Compose or the reply box never waits for it. */
export function preloadEditor() {
  if (preloaded) return
  preloaded = true
  void load()
}

/**
 * The rich-text editor, loaded on first use. Until it arrives, a stand-in of the same size shows the placeholder,
 * so nothing jumps.
 */
export function Editor(props: RichTextProps) {
  // Already fetched (it preloads while the page is idle): mount it in this very render.
  if (loaded) return <loaded.default {...props} />
  return (
    <React.Suspense fallback={<Standin {...props} />}>
      <RichText {...props} />
    </React.Suspense>
  )
}
```

The page loads about 337 kB gzipped without the editor, and the editor is in the browser before anyone opens Compose. When it isn't yet, a stand-in of the same size shows the placeholder, so nothing jumps when the real editor takes its place.

The editor sends on ⌘↵ (Ctrl+Enter on Windows) and closes on Escape, through ProseMirror's own key handler:

`components/mail/rich-text.tsx`, lines 101–111:

```tsx
      handleKeyDown: (_view, event) => {
        if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
          callbacks.current.onSubmit?.()
          return true
        }
        if (event.key === "Escape" && callbacks.current.onEscape) {
          callbacks.current.onEscape()
          return true
        }
        return false
      },
```

The Compose window has a ⌘↵ of its own, for when the focus is in the subject or a recipient field:

`components/mail/compose-window.tsx`, lines 194–205:

```tsx
  const onKeyDown = (event: React.KeyboardEvent) => {
    // The editor handles its own ⌘↵ and marks the event as handled.
    if (event.defaultPrevented) return
    if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
      event.preventDefault()
      send()
    }
    if (event.key === "Escape" && mode === "fullscreen" && !phone) {
      event.preventDefault()
      setMode("expanded")
    }
  }
```

> **Lesson: ProseMirror handles Cmd/Ctrl+Enter and marks the event handled: a window-level shortcut must check defaultPrevented** (My own mistake; learned by the AI (Claude Opus 5.5); @tiptap/react@3)
> **I assumed:** the editor's ⌘↵ and the Compose window's ⌘↵ could both stay, each covering its own part of the window.
> **What happened:** a ⌘↵ inside the editor ran the editor's submit and then bubbled up to the window's handler, which sent again: one keystroke, two sends.
> **The fix:** the editor's `handleKeyDown` returns `true` when it handles the key, which makes ProseMirror call `preventDefault()`, and the window's handler returns at once for an event that is `defaultPrevented`.
> **Evidence:** commit 2e3b352; rich-text.tsx lines 101–111 and compose-window.tsx lines 194–200.

## Attachments

Files come from a picker or a drop. After a pick, the input is cleared so the same file can be picked again:

`components/mail/compose-window.tsx`, lines 176–192:

```tsx
  }

  const addFiles = (list: FileList | null) => {
    if (!list?.length) return
    // Copy now: the input is cleared right after, which empties the live FileList.
    const picked = [...list]
    setFiles((current) => [
      ...current,
      ...picked.map((f) => ({
        id: `${f.name}-${f.size}-${f.lastModified}`,
        name: f.name,
        size: f.size,
        kind: attachmentKind(f.name, f.type),
        url: URL.createObjectURL(f),
      })),
    ])
  }
```

> **Lesson: An input's FileList is live: copy it before clearing the input, or a deferred state update reads an empty list** (My own mistake; learned by the AI (Claude Opus 5.5))
> **I assumed:** `setFiles((current) => [...current, ...input.files])` followed by `input.value = ""` would add the picked files.
> **What happened:** `input.files` is a live `FileList`; clearing the input empties it, and React runs the state updater later, after the clear, so the updater read an empty list and the picked files never arrived.
> **The fix:** copy first, `const picked = [...list]`, and build the attachments from the copy.
> **Evidence:** commit 2e3b352; compose-window.tsx line 181, and the same in inline-reply.tsx.

## Autosave, and a window that animates out

Compose saves into Drafts a moment after the last change. Sending, discarding or closing the window should end that, but the window doesn't unmount at once: it animates out inside `AnimatePresence`.

`components/mail/compose-window.tsx`, lines 97–120:

```tsx
  // Autosave into Drafts a moment after the last change. Once the window is sent, discarded or closed it saves
  // nothing more, even while it animates out.
  const first = React.useRef(true)
  const done = React.useRef(false)
  const autosave = React.useRef<ReturnType<typeof setTimeout>>(undefined)
  const finish = () => {
    done.current = true
    clearTimeout(autosave.current)
  }
  React.useEffect(() => {
    if (first.current) {
      first.current = false
      return
    }
    if (!hasContent || done.current) return
    autosave.current = setTimeout(() => {
      if (done.current) return
      draftId.current = actions.saveDraft(draft(), draftId.current)
      setSavedAt(Date.now())
    }, AUTOSAVE_MS)
    return () => clearTimeout(autosave.current)
    // Runs when the content changes; draft() reads the same values.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [to, cc, bcc, subject, html, files])
```

> **Lesson: A component kept alive by AnimatePresence for its exit still runs its timers: stop them when the action happens** (My own mistake; learned by the AI (Claude Opus 5.5); motion@14)
> **I assumed:** an autosave timer cleared in the effect's cleanup would stop when the Compose window was sent, since the window goes away.
> **What happened:** after Send, the window stayed mounted while it animated out, so its pending autosave fired during the exit and saved the message that had just been sent back into Drafts.
> **The fix:** a `done` ref that send, discard and close set at the moment they happen, which clears the timer and turns any late timer into a no-op; the effect also skips scheduling once it is set.
> **Evidence:** commit 2e3b352; the comment "Once the window is sent, discarded or closed it saves nothing more, even while it animates out" in compose-window.tsx.

## The list: sticky day headers

The list groups threads by day under sticky headers that stay on top of the rows as they scroll by:

`components/mail/mail-list.tsx`, lines 185–198:

```tsx
        <AnimatePresence initial={false}>
          {groups.map((group) =>
            [
              group.title ? (
                <motion.li
                  key={`group-${group.title}`}
                  layout={!reduce}
                  exit={{ opacity: 0 }}
                  transition={{ duration: reduce ? 0 : 0.18 }}
                  className="sticky top-0 z-20 -mx-2 bg-background/85 px-5 pt-2 pb-1.5 text-[11px] font-medium tracking-wider text-muted-foreground uppercase backdrop-blur-sm"
                >
                  {group.title}
                </motion.li>
              ) : null,
```

> **Lesson: Sticky list headers with a z-index paint over overlays outside the list: give the list its own stacking context** (My own mistake; learned by the AI (Claude Opus 5.5))
> **I assumed:** `z-20` on the sticky headers only ranked them against the rows scrolling under them.
> **What happened:** without a stacking context of their own, the headers' z-index competed with everything else on the page, and they painted over an overlay laid over the list.
> **The fix:** `isolate` (`isolation: isolate`) on the list's root and on the mail root, so the z-indexes inside compete only with each other.
> **Evidence:** commit 2e3b352; mail-list.tsx line 231 and mail.tsx line 123.

## Contrast on shadcn's own palette

The selected and checked rows sit on a muted surface, and their secondary text needed one more token:

`components/mail/parts.tsx`, lines 42–46:

```tsx
/**
 * Muted text that sits on a muted or accent surface. shadcn's muted-foreground on muted is 4.3:1, a little under AA;
 * the foreground at 60% is the same palette and clears it in both themes.
 */
export const TEXT_ON_MUTED = "text-foreground/60"
```

> **Lesson: shadcn's muted-foreground on a muted surface is about 4.3:1, just under WCAG AA: use the foreground at 60% there** (Not in the docs; learned by the AI (Claude Opus 5.5))
> **I assumed:** `text-muted-foreground` was safe for secondary text on any of shadcn's surfaces.
> **What happened:** shadcn's `muted-foreground` on its `muted` surface measures 4.34:1, under the 4.5:1 that WCAG AA asks of body text, and selected rows and the active tab put secondary text on exactly that kind of surface.
> **The fix:** `TEXT_ON_MUTED = "text-foreground/60"`, the same palette, which clears 4.5:1 in both themes; axe finds no violations on seven screens in light and dark.
> **Evidence:** commit 2e3b352; parts.tsx lines 42–46 and the project's axe run.

## Checked

22 unit tests, an end-to-end script of 108 checks run on the dev and production builds, axe on seven screens in both themes, and screenshots at 1440, 1024, 768 and 390 px. On UpFork it went through the importer, the preview builder and the install check, which installs it with the real shadcn CLI into a fresh Next.js project.

## Make it yours

`<Mail>` takes your threads and contacts in the shape of `types.ts`, `onSend` and `onReply` to hand messages to your back end, and a `defaultThreadId` to open on; everything else (folders, labels, snooze, Undo) works on the store with no back end at all. It wears your shadcn theme, light and dark.

```bash
npx shadcn@latest add @upfork/email-client-inbox-react-shadcn
```

---

On UpFork: https://upfork.dev/articles/an-email-client-on-shadcn-ui-lazy-tiptap-autosave-and-six-traps-in-radix-rea
