EmpireUI
Get Pro
← Blog9 min read#email client#ui design#react

Email Client UI Design: Three-Pane Layout, Compose Modal, Labels

Build a production-ready email client UI in React: three-pane layout, resizable panels, compose modal, and label system — with real code and zero fluff.

Email client interface with three-pane layout on dark screen

Why Email Client UIs Are Uniquely Hard

Email clients are one of those UIs that look simple until you're three weeks in and realise you've accidentally built a spreadsheet. The three-pane layout — folder sidebar, message list, reading pane — has been the standard since Outlook 97. That's almost 30 years of muscle memory you're working with. Break that mental model and users will hate you for it.

Honestly, most developers underestimate the density problem. A typical inbox displays 30–50 message rows simultaneously, each with a sender name, subject, preview snippet, timestamp, label badges, and a read/unread indicator — all crammed into a row that's maybe 72px tall. Every pixel counts. You're not designing a card UI; you're designing a data-dense list that has to feel effortless.

That said, the component surface area is actually manageable if you break it down correctly: layout shell, sidebar nav, message list, reading pane, and compose modal. Five distinct units. Build each one in isolation and compose them — that's the approach this article takes.

Quick aside: we're building with React 18+ and Tailwind CSS 3.4. No UI kit required, though you'll notice some of the patterns here echo what you'd find if you browse components on Empire UI — particularly the resizable panels and modal primitives.

The Three-Pane Layout Shell

The shell is a CSS Grid at its core. Three columns: a fixed-width sidebar (220–260px), a resizable message list (280–360px), and a flex-fill reading pane. On screens narrower than 768px you collapse to a single-column flow and push the reading pane to a drawer or new route. Keep it simple — don't try to cram all three panes onto a 375px screen.

Here's the base layout shell. This uses CSS Grid with a resize handle for the middle column — no external library needed for the basic version: ``tsx // EmailShell.tsx export function EmailShell() { const [listWidth, setListWidth] = React.useState(320); return ( <div className="h-screen w-full overflow-hidden grid" style={{ gridTemplateColumns: 240px ${listWidth}px 1fr, }} > <aside className="border-r border-zinc-200 dark:border-zinc-800 overflow-y-auto"> <SidebarNav /> </aside> <div className="relative border-r border-zinc-200 dark:border-zinc-800 overflow-hidden flex flex-col"> <MessageList /> {/* drag handle */} <ResizeHandle onDrag={(dx) => setListWidth((w) => Math.max(220, Math.min(480, w + dx))) } /> </div> <main className="overflow-y-auto"> <ReadingPane /> </main> </div> ); } ``

The ResizeHandle is just a 4px-wide div with a cursor-col-resize class and pointer-event listeners. Clamp the width between 220px and 480px — outside those bounds the UI falls apart on anything less than a 13-inch laptop. Worth noting: if you want a more sophisticated drag-resize without reinventing the wheel, @radix-ui/react-separator combined with a useDrag hook keeps the component count low.

One more thing — add overflow-hidden on the shell and overflow-y-auto on each individual pane, not the reverse. If you put scroll on the shell, keyboard navigation and focus management inside each pane gets weird fast. Learned that the hard way in 2024 on a client project.

Sidebar Nav: Folders, Labels, and Counts

The sidebar needs to communicate two things at a glance: where you are, and what needs attention. Active state should be obvious — a solid fill on the row, not just a border-left hack. Unread counts belong in a small badge on the right, right-aligned to the column edge. Don't indent them or float them — right-align, always.

// SidebarNav.tsx
const NAV_ITEMS = [
  { icon: InboxIcon, label: 'Inbox', count: 12, id: 'inbox' },
  { icon: StarIcon, label: 'Starred', count: 0, id: 'starred' },
  { icon: PaperAirplaneIcon, label: 'Sent', count: 0, id: 'sent' },
  { icon: ArchiveIcon, label: 'Archive', count: 0, id: 'archive' },
  { icon: TrashIcon, label: 'Trash', count: 3, id: 'trash' },
];

function NavItem({ item, active, onClick }) {
  return (
    <button
      onClick={onClick}
      className={[
        'w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
        active
          ? 'bg-blue-600 text-white'
          : 'text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800',
      ].join(' ')}
    >
      <item.icon className="w-4 h-4 shrink-0" />
      <span className="flex-1 text-left truncate">{item.label}</span>
      {item.count > 0 && (
        <span
          className={[
            'text-xs font-semibold px-1.5 py-0.5 rounded-full',
            active ? 'bg-white/20 text-white' : 'bg-blue-100 text-blue-700',
          ].join(' ')}
        >
          {item.count}
        </span>
      )}
    </button>
  );
}

Below the system folders, render user-created labels with a colour dot instead of an icon. Keep the pattern identical — same height, same padding, same badge treatment. Consistency here isn't pedantic; it's what makes the sidebar scannable at 6am before coffee. The colour dot itself should be 8px wide (w-2 h-2 rounded-full) with an inline-block so it doesn't grow or shrink.

In practice, you'll want a 'New Label' button at the bottom of the sidebar that opens an inline input, not a modal. Modals feel heavy for a two-field operation (name + colour). An inline input that appears below the last label, autofocuses, and submits on Enter — that's the Gmail pattern and it's genuinely the right call.

Message List: Density, Read State, Selection

This is where you either win or lose the user. The message list is the highest-interaction surface in the whole app. You're clicking here dozens of times per session. It needs to be fast — visually and actually.

Each row carries: checkbox (shows on hover or when any row is checked), sender avatar or initials, sender name, subject, preview text, and timestamp. On a 320px column that's a lot. Use a two-line layout: line one gets sender name + timestamp right-aligned; line two gets subject in medium weight and preview in muted colour, both truncated with truncate or line-clamp-1. Keep the row height at exactly 72px — any shorter and touch targets suffer, any taller and you lose list density.

// MessageRow.tsx
function MessageRow({ message, selected, active, onSelect, onClick }) {
  const isUnread = !message.readAt;

  return (
    <div
      role="option"
      aria-selected={active}
      onClick={onClick}
      className={[
        'group relative flex items-start gap-3 px-4 py-3 cursor-pointer border-b border-zinc-100 dark:border-zinc-800',
        'h-[72px] overflow-hidden',
        active ? 'bg-blue-50 dark:bg-blue-950' : 'hover:bg-zinc-50 dark:hover:bg-zinc-900',
        selected ? 'bg-blue-50/60 dark:bg-blue-950/60' : '',
      ].join(' ')}
    >
      {/* checkbox — visible on hover or when any selected */}
      <div className="mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
        <input
          type="checkbox"
          checked={selected}
          onChange={(e) => { e.stopPropagation(); onSelect(); }}
          className="w-4 h-4 rounded accent-blue-600"
        />
      </div>

      {/* unread indicator */}
      {isUnread && (
        <span className="absolute left-1.5 top-1/2 -translate-y-1/2 w-1.5 h-1.5 rounded-full bg-blue-600" />
      )}

      {/* sender avatar */}
      <Avatar name={message.from.name} size={32} />

      <div className="flex-1 min-w-0">
        <div className="flex items-baseline justify-between gap-2">
          <span className={`text-sm truncate ${isUnread ? 'font-semibold text-zinc-900 dark:text-zinc-100' : 'text-zinc-700 dark:text-zinc-300'}`}>
            {message.from.name}
          </span>
          <span className="text-xs text-zinc-400 shrink-0">{formatRelative(message.date)}</span>
        </div>
        <p className={`text-sm truncate ${isUnread ? 'font-medium text-zinc-800 dark:text-zinc-200' : 'text-zinc-500'}`}>
          {message.subject}
        </p>
        <p className="text-xs text-zinc-400 truncate">{message.preview}</p>
      </div>
    </div>
  );
}

Selection mode — when the user checks one or more rows — should reveal a contextual toolbar above the list. Don't hide it in a dropdown. Show the four most-used bulk actions inline: Archive, Delete, Mark read/unread, Label. If there are more than four actions, put the rest behind a 'More' button. Contextual toolbars that appear only when needed feel magical compared to a permanently visible action bar.

For virtualization: if you're rendering more than 200 messages, you need @tanstack/react-virtual. No debate. Without it, DOM size on a large inbox kills scroll performance on anything below a modern M-series chip. The setup is four lines of code with the useVirtualizer hook and it's completely worth it.

Compose Modal: Focus Trap, Rich Toolbar, Minimise

The compose window is the quirkiest piece of the whole UI. It's not a standard modal — it doesn't block the rest of the app. You need to read an email while composing a reply; that's the whole point. So the compose 'modal' is actually a fixed-position overlay anchored to the bottom-right corner, like Gmail's. It can be minimised to a slim title bar. It can be full-screened. It can coexist with two or three other compose windows.

// ComposeModal.tsx
function ComposeModal({ id, onClose, onMinimise, isMinimised }) {
  const [to, setTo] = React.useState('');
  const [subject, setSubject] = React.useState('');
  const [body, setBody] = React.useState('');

  return (
    <div
      className={[
        'fixed bottom-0 right-6 w-[480px] bg-white dark:bg-zinc-900 rounded-t-xl shadow-2xl border border-zinc-200 dark:border-zinc-700 flex flex-col z-50',
        isMinimised ? 'h-10' : 'h-[520px]',
        'transition-[height] duration-200 ease-in-out',
      ].join(' ')}
      role="dialog"
      aria-label="New message"
    >
      {/* title bar */}
      <div className="flex items-center justify-between px-4 h-10 shrink-0 bg-zinc-800 dark:bg-zinc-950 rounded-t-xl cursor-pointer" onClick={onMinimise}>
        <span className="text-sm font-medium text-white truncate">
          {subject || 'New Message'}
        </span>
        <div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
          <button onClick={onMinimise} className="text-zinc-400 hover:text-white">—</button>
          <button onClick={onClose} className="text-zinc-400 hover:text-white">✕</button>
        </div>
      </div>

      {!isMinimised && (
        <>
          <div className="border-b border-zinc-100 dark:border-zinc-800 px-4 py-2">
            <input value={to} onChange={(e) => setTo(e.target.value)}
              placeholder="To"
              className="w-full text-sm outline-none bg-transparent text-zinc-800 dark:text-zinc-200"
            />
          </div>
          <div className="border-b border-zinc-100 dark:border-zinc-800 px-4 py-2">
            <input value={subject} onChange={(e) => setSubject(e.target.value)}
              placeholder="Subject"
              className="w-full text-sm outline-none bg-transparent font-medium text-zinc-800 dark:text-zinc-200"
            />
          </div>
          <textarea
            value={body}
            onChange={(e) => setBody(e.target.value)}
            className="flex-1 resize-none px-4 py-3 text-sm outline-none bg-transparent text-zinc-700 dark:text-zinc-300"
            placeholder="Write your message..."
          />
          <ComposeToolbar onSend={() => handleSend({ to, subject, body })} />
        </>
      )}
    </div>
  );
}

That transition-[height] on the minimise animation is doing a lot of work. Make sure height is in your tailwind.config.js transitionProperty extension if you're on Tailwind 3.3 or earlier — otherwise the class has no effect and you get a jarring snap. On Tailwind 3.4+ it's included by default.

The ComposeToolbar at the bottom should have: Bold, Italic, Underline, Link, Attach file, Emoji, and a Send button. Eight items max. Beyond that you're building an email newsletter editor, not a compose window. Space them with 4px gaps and use 16px icon size — 14px is too small to hit reliably on touch. Look, the compose window is where drafts live; make sure you auto-save to localStorage every 3 seconds so a tab crash doesn't lose a long email.

For multiple simultaneous compose windows, store them in a top-level array in your state manager. Render them offset: the second one sits 496px from the right (right-[496px]), the third at 992px. Cap at three — beyond that the UI becomes unusable on a 1440px screen. Add a 'X more compose windows' collapsed indicator if the user somehow opens more.

Labels: Colour System, Application, Filtering

Labels are many-to-many — one email can have five labels. That changes the data model from a simple folder: string to a labelIds: string[] array. Get the data model right first or you'll be refactoring the whole message list in week two. Each label has an id, name, color (a hex string or one of 12 preset colours), and an optional threadCount.

Visually, labels in the message list are pill badges — but don't render more than two badges per row before truncating to +N more. The reading pane can show all of them. This keeps the message list from looking like a Christmas tree.

// LabelBadge.tsx
const LABEL_PRESETS = [
  { name: 'red', bg: '#fecaca', text: '#991b1b' },
  { name: 'orange', bg: '#fed7aa', text: '#9a3412' },
  { name: 'yellow', bg: '#fef08a', text: '#854d0e' },
  { name: 'green', bg: '#bbf7d0', text: '#166534' },
  { name: 'blue', bg: '#bfdbfe', text: '#1e40af' },
  { name: 'purple', bg: '#e9d5ff', text: '#6b21a8' },
  { name: 'gray', bg: '#e4e4e7', text: '#3f3f46' },
];

function LabelBadge({ label }: { label: Label }) {
  const preset = LABEL_PRESETS.find((p) => p.name === label.color)
    ?? LABEL_PRESETS[4];

  return (
    <span
      className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
      style={{ backgroundColor: preset.bg, color: preset.text }}
    >
      {label.name}
    </span>
  );
}

Applying a label should happen via a popover triggered from the toolbar — not a modal, not a dropdown that dismisses on outside click. Use a popover that stays open while you're clicking checkboxes. The @radix-ui/react-popover with modal={false} is the right tool here. Inside the popover: a search input, a list of labels with checkboxes reflecting the current message's applied labels, and a 'Create new label' button at the bottom.

Filtering by label in the message list is a server-side concern in production, but for local state you'd filter messages.filter(m => m.labelIds.includes(activeLabelId)). Cache the filtered results with useMemo keyed on [messages, activeLabelId] — without that, a large message array gets re-filtered on every keystroke in the compose window.

Polish: Keyboard Shortcuts, Dark Mode, and Final Details

Keyboard shortcuts aren't optional in an email client — they're what separates a tool people use daily from one they abandon. At minimum you need: c to compose, e to archive, # to delete, r to reply, j/k to navigate messages, / to focus search. Register them with a useEffect and document.addEventListener('keydown', ...) behind a guard that checks document.activeElement isn't an input or textarea.

Dark mode in a three-pane layout has one specific gotcha: the reading pane renders HTML email content inside an <iframe> or a sanitised dangerouslySetInnerHTML. That content is white-background by default and you can't just invert it without wrecking images and branded emails. The cleanest approach is a subtle rounded-lg card around the email body with a fixed white background in dark mode — bg-white text-zinc-900 — as a reading surface inside the dark shell. Feels like paper-in-dark-environment and it works.

One more thing — the avatar system. Every sender should show a 32px avatar: first try loading a Gravatar URL built from the email hash, then fall back to a coloured circle with the sender's initials. The colour for the initials avatar should be deterministic based on the name — hash the name to a hue value so the same person always gets the same colour. Users notice when colours change between sessions.

If you want to skin the whole shell with a distinctive aesthetic — something beyond the default grey-on-white — Empire UI's glassmorphism components or the cyberpunk style hub are worth a look. An email client with a glassmorphism sidebar over an aurora gradient background is genuinely striking, and it only takes swapping 8–10 Tailwind classes. The gradient generator can help you dial in the background colours without guessing.

FAQ

Do I need a virtualisation library for the message list?

Yes, once you're past ~200 messages. Use @tanstack/react-virtual — the useVirtualizer hook takes about four lines to wire up and completely eliminates scroll jank on large inboxes. Without it, DOM size becomes the bottleneck.

How do I handle the compose window not blocking the reading pane?

Don't use a standard dialog modal. Render the compose window as a fixed-position overlay anchored to bottom-0 right-6 with its own z-index. It sits above the shell but doesn't block pointer events on the panes behind it — no inert attribute, no backdrop overlay.

What's the right way to handle many-to-many labels on a message?

Store labelIds: string[] on each message object, not a single folder string. Filter the message list with messages.filter(m => m.labelIds.includes(id)) and wrap it in useMemo to avoid re-filtering on every render cycle.

How do I render HTML email content safely inside the reading pane?

Use an <iframe srcDoc={sanitisedHtml}> with sandbox="allow-same-origin" for the safest option, or run the HTML through DOMPurify before using dangerouslySetInnerHTML. Never render unsanitised email HTML directly — it's a real XSS vector.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

File Manager UI Design: Grid/List Toggle, Breadcrumbs, Drag UploadSaaS Onboarding UI: Checklists, Progress Steps and Empty StatesStepper Component in React: Multi-Step Forms and OnboardingSkeleton Loader in React: Pulse Animation and Smart Loading States