Source: https://monarchui.dev/components/split-inbox-layout/

Patterns · Layout

# SplitInboxLayout

The one-page inbox: list, detail and context panes, resizable and collapsible, single-pane with back navigation on phones.

@rookcom/monarch-react/patterns

Pattern

## Inbox with list, conversation and details

## Usage examples

The example at the top of the page is the only one for this component.

## Purpose

Lays out a whole inbox on one page: a list pane, a detail pane and an optional context pane (requester, properties, activity). Panes resize with the pointer and the keyboard and collapse; below 768px it becomes one pane with a back button, and below 1280px the context pane becomes a drawer. The product supplies every pane’s content and owns the selection and URL state. One powerful page instead of five similar ones.

### Use when

- A queue of items people work through one by one: tickets, conversations, applications, review requests.
- The list, the open item and its context should stay on screen together.

### Avoid when

- A plain list-and-detail without resizing or a context pane: use SplitLayout.
- A table people scan and sort: use DataTable on its own page.

## Design principles

- Content-agnostic: the layout never fetches, routes or selects; it shows what it is given.
- Every state is a prop: `detailOpen`, `listCollapsed`, `contextOpen` and the sizes can live in the URL or storage, with change callbacks.
- The same layout from 320px to wide screens: one pane, two panes, three panes.
- Panes are landmarks, and focus moves with what the user is looking at.

## API reference

```tsx
import { SplitInboxContextToggle, SplitInboxLayout, useSplitInboxLayout } from '@rookcom/monarch-react/patterns';
```

| Export | Kind | Rendering | Root class |
| --- | --- | --- | --- |
| `SplitInboxContextToggle` | component | client | `.mn-split-inbox` |
| `SplitInboxLayout` | component | client | `.mn-split-inbox` |
| `useSplitInboxLayout` | hook | client | – |

Renders a `<div>` with the list (`<aside>`), the detail (`<section>`) and the context (`<aside>`, or a Sheet below 1280px). Forwards its ref.

The splitters are APG window splitters: `role="separator"` with `aria-valuenow` (px), `aria-valuemin`, `aria-valuemax` and `aria-controls`.

SplitInboxContextToggle is the details button for the detail pane’s header: it toggles the column on wide screens and the drawer otherwise, with `aria-expanded`.

`useSplitInboxLayout()` returns `{ layout, contextMode, contextVisible, setContextVisible, toggleContext, listCollapsed, setListCollapsed, focusPane, ids }` inside a pane (or `null`), for custom toggles such as a focus-mode button.

Breakpoints are fixed (768px and 1280px) and shared by the stylesheet and the component, so the first render matches the server.

Exported from `@rookcom/monarch-react/patterns`.

Generated from the TypeScript declarations of `packages/react/src/patterns/layout/split-inbox-layout`.

## Properties

### `SplitInboxContextToggle`

The button that shows and hides the context pane, for the detail pane's header. It toggles the column on wide screens and the drawer otherwise, and exposes the state with `aria-expanded`.

| Prop | Type | Description |
| --- | --- | --- |
| `asChild` | `boolean` | Render the child element (e.g. a Next.js <Link>) with button styling. |
| `brandColor` | `string` | Brand or tenant colour for `variant="brand"`, as `#rrggbb` or `#rgb`. The fill and text are derived per theme by the tokens contrast engine, so the label always meets WCAG AA (7:1 in more-contrast themes); the colour may be darkened or lightened to get there. Ignored by other variants. An invalid or missing colour falls back to the product accent. |
| `className` | `string` |  |
| `disabled` | `boolean` |  |
| `hideLabel` | `string` | Label while it is showing. Default "Hide details". |
| `icon` | `ReactNode` | Replaces the default panel icon. Decorative. |
| `id` | `string` |  |
| `loading` | `boolean` | Shows a spinner, sets aria-busy and blocks clicks while keeping focus. |
| `showLabel` | `string` | Label while the context pane is hidden. Default "Show details". |
| `size` | `ButtonSize` |  |
| `variant` | `ButtonVariant` | Visual weight. Use one `primary` per view region. Default `secondary`. |

### `SplitInboxLayout`

The one-page inbox: list, detail and an optional context pane. Panes are resizable with the pointer and the keyboard and collapsible; below 768px it becomes one pane with back navigation, and below 1280px the context pane becomes a drawer. F6 and Shift+F6 move focus between panes. The product supplies every pane's content and owns the URL state.

| Prop | Type | Description |
| --- | --- | --- |
| `detail` required | `ReactNode` | The detail pane: the open item, or an EmptyState when nothing is selected. |
| `list` required | `ReactNode` | The list pane: queue switcher, search, the conversation list. |
| `backLabel` | `string` | Label of that back button. Default "Back to list". |
| `children` | `ReactNode` |  |
| `className` | `string` |  |
| `context` | `ReactNode` | The optional context pane: requester, properties, activity. |
| `contextCloseLabel` | `string` | Accessible name of the drawer's close button. Default "Close details". |
| `contextLabel` | `string` | Name of the context pane landmark and the drawer title. Default "Details". |
| `contextOpen` | `boolean` | Whether the context column shows on wide screens (controlled). Below 1280px the context pane is a drawer with its own open state, closed at first, so a remembered "open" never opens a modal on page load. |
| `contextResizeLabel` | `string` | Default "Resize details". |
| `contextSize` | `number` | Width of the context column in px (controlled). |
| `defaultContextOpen` | `boolean` | Default true. |
| `defaultContextSize` | `number` | Initial context width in px. Default 340. |
| `defaultListCollapsed` | `boolean` |  |
| `defaultListSize` | `number` | Initial list width in px. Default 360. |
| `detailLabel` | `string` | Name of the detail pane landmark. Default "Detail". |
| `detailOpen` | `boolean` | Below 768px only one pane shows: the list, or the detail when this is true. Keep it in the URL (an open item id) so Back works. |
| `id` | `string` |  |
| `listCollapsed` | `boolean` | Hides the list on wide screens: focus or full-page mode (controlled). |
| `listLabel` | `string` | Name of the list pane landmark. Default "List". |
| `listResizeLabel` | `string` | Default "Resize list". |
| `listSize` | `number` | Width of the list pane in px (controlled). |
| `maxContextSize` | `number` | Default 520. |
| `maxListSize` | `number` | Default 560. |
| `minContextSize` | `number` | Default 260. |
| `minListSize` | `number` | Default 240. |
| `onBack` | `(() => void)` | Renders a back button at the top of the detail pane below 768px. |
| `onContextOpenChange` | `((open: boolean) => void)` |  |
| `onContextSizeChange` | `((size: number) => void)` |  |
| `onListCollapsedChange` | `((collapsed: boolean) => void)` |  |
| `onListSizeChange` | `((size: number) => void)` | Called while the list is resized. Persist it to remember the width. |
| `resizable` | `boolean` | Show the keyboard and pointer splitters between panes. Default true. |
| `resizeStep` | `number` | Pixels per arrow key press (Shift: four times). Default 16. |

## Variants

| Prop | Values | Description |
| --- | --- | --- |
| `context` | `'omitted'``'provided'` | Two panes, or three with the context pane and its splitter. |
| `resizable` | `'true'``'false'` | Shows the splitters. Without them panes keep their sizes. |
| `layout (from the viewport)` | `'split (768px and up)'``'single (below 768px)'` | Exposed as `data-layout`. In `single`, `detailOpen` picks the pane. |

## States

| State | Exposed as | Behaviour |
| --- | --- | --- |
| Detail open | `data-detail-open` | Below 768px the detail pane replaces the list. |
| List collapsed (focus mode) | `data-list-collapsed` | On wide screens the list is hidden; its splitter stays as a thin handle that expands it. |
| Context open | `data-context-open` | The context column shows (1280px and up). |
| Context drawer | – | Below 1280px the context opens as a modal Sheet; it starts closed so a remembered "open" never opens a modal on load. |

## Interaction behaviour

- Drag a splitter to resize its pane, clamped to the min and max; sizes are reported through `onListSizeChange` and `onContextSizeChange`.
- Clicking the collapsed list handle expands the list.
- Below 768px, opening an item (`detailOpen`) moves focus to the detail pane; going back returns focus to the element that opened it.
- Closing the drawer returns focus to the button that opened it.

## Keyboard support

| Keys | Action |
| --- | --- |
| F6 / Shift+F6 | Moves focus to the next or previous visible pane, back to the element focused there last. |
| ArrowLeft / ArrowRight | On a splitter, moves it by `resizeStep` (16px); with Shift, four times as far. |
| Home / End | On a splitter, sets its pane to the minimum or maximum size. |
| Enter | On the list splitter, collapses or expands the list; on the context splitter, closes the context pane. |
| Escape | Closes the context drawer. |

## Accessibility

Role

complementary (list), region (detail), complementary (context) landmarks

Accessible name

listLabel

,

detailLabel

and

contextLabel

.

- Each splitter is focusable and named (`listResizeLabel`, `contextResizeLabel`) with its value in pixels.
- Panes are programmatic focus targets (`tabindex="-1"`) with a visible focus ring when reached by F6.
- The drawer is a modal dialog titled by `contextLabel` with a named close button; focus is trapped inside it.
- Arrow keys follow the reading direction in right-to-left layouts.

## Responsive behaviour

- 1280px and up: list, detail and context side by side.
- 768px to 1279px: list and detail; the context pane is a drawer.
- Below 768px: one pane at a time with a back button in the detail pane; splitters are hidden.
- The layout fills its container (`height: 100%`); give the container a height.

## Theme support

- Pane surfaces, borders and the splitter highlight use Monarch tokens, so every theme applies.
- In forced-colours mode the splitter highlight uses the system Highlight colour.
- The splitter highlight transition is removed with reduced motion.

## Do and don't

### Do

- Keep the open item, focus mode and pane sizes in the URL or storage, and pass them in.
- Give the container a fixed height (the viewport minus the app header).
- Name the panes after their content: "Conversations", "Conversation", "Ticket details".

### Don't

- Do not build separate pages for the list and the item; this is the one page.
- Do not move focus into the detail pane yourself on wide screens; the user may still be in the list.
- Do not put a second SplitInboxLayout inside a pane.

## Related components

- [SplitLayout](https://monarchui.dev/components/split-layout/): A simpler two-pane list and detail.
- [RecordHeader](https://monarchui.dev/components/record-header/): The header of the open item in the detail pane.
- [Sheet](https://monarchui.dev/components/sheet/): The drawer the context pane becomes on narrower screens.
- [EmptyState](https://monarchui.dev/components/empty-state/): The detail pane before anything is selected.

## Dependencies

| Kind | Depends on |
| --- | --- |
| npm packages | `react` |
| Monarch components | [Button](https://monarchui.dev/components/button/), [Sheet](https://monarchui.dev/components/sheet/) |

## Testing coverage

- Renders the panes as labelled landmarks with two splitters; ref forwarding.
- List splitter: arrows, Shift+arrows, Home/End and clamping; Enter collapses and an arrow expands (uncontrolled).
- Controlled sizes change only through the parent.
- Pointer drag resizes both panes in the right direction, clamped; moves after release are ignored.
- Context splitter: ArrowLeft widens; Enter closes the pane and moves focus to the detail.
- SplitInboxContextToggle toggles the context column with aria-expanded and aria-controls.
- Below 1280px the context is a modal drawer, closed at first; Escape closes it and returns focus to the toggle.
- Below 768px one pane shows; opening moves focus to the detail and Back returns it to the opener.
- F6 and Shift+F6 cycle the visible panes, returning to the last focused element.
- A collapsed list is skipped by F6; useSplitInboxLayout exposes the state and is null outside.
- Renders on the server in the wide layout.

| Check | Covered |
| --- | --- |
| Axe in jsdom | Yes |
| Server rendering | Yes |
| Browser suites | Visual baselines: /__gallery/split-inbox-layout; Cross-browser axe: /components/split-inbox-layout |

Tests: `packages/react/src/patterns/layout/split-inbox-layout`

## All example code

### Inbox with list, conversation and details

```tsx
import { useState } from 'react';
import { Badge, DescriptionItem, DescriptionList, EmptyState } from '@rookcom/monarch-react';
import { RecordHeader, SplitInboxContextToggle, SplitInboxLayout } from '@rookcom/monarch-react/patterns';

const conversations = [
  {
    id: 'c1',
    subject: 'Printer offline on floor 3',
    from: 'Grace Hopper',
    preview: 'It stopped after the update this morning.',
  },
  {
    id: 'c2',
    subject: 'Invoice shows the wrong address',
    from: 'Alan Turing',
    preview: 'Our billing address changed in March.',
  },
  {
    id: 'c3',
    subject: 'Reset two-factor',
    from: 'Katherine Johnson',
    preview: 'I replaced my phone and lost the codes.',
  },
];

const listStyle = { listStyle: 'none', margin: 0, padding: 'var(--mn-space-2)' } as const;

const itemStyle = (active: boolean) =>
  ({
    display: 'grid',
    gap: '0.125rem',
    width: '100%',
    padding: 'var(--mn-space-2) var(--mn-space-3)',
    border: 0,
    borderRadius: 'var(--mn-radius-md)',
    background: active ? 'var(--mn-color-accent-subtle)' : 'transparent',
    color: 'inherit',
    font: 'inherit',
    textAlign: 'start',
    cursor: 'pointer',
  }) as const;

function InboxDemo() {
  const [openId, setOpenId] = useState<string | null>('c1');
  const open = conversations.find((c) => c.id === openId) ?? null;
  return (
    <div
      style={{
        height: '26rem',
        width: '100%',
        border: '1px solid var(--mn-color-border-default)',
        borderRadius: 'var(--mn-radius-lg)',
        overflow: 'hidden',
      }}
    >
      <SplitInboxLayout
        listLabel="Conversations"
        detailLabel="Conversation"
        contextLabel="Ticket details"
        defaultListSize={280}
        defaultContextSize={260}
        detailOpen={open !== null}
        onBack={() => setOpenId(null)}
        backLabel="Back to conversations"
        list={
          <ul style={listStyle}>
            {conversations.map((c) => (
              <li key={c.id}>
                <button
                  type="button"
                  style={itemStyle(c.id === openId)}
                  aria-current={c.id === openId ? 'true' : undefined}
                  onClick={() => setOpenId(c.id)}
                >
                  <strong style={{ fontSize: 'var(--mn-text-sm)' }}>{c.subject}</strong>
                  <span
                    style={{ fontSize: 'var(--mn-text-xs)', color: 'var(--mn-color-text-muted)' }}
                  >
                    {c.from} · {c.preview}
                  </span>
                </button>
              </li>
            ))}
          </ul>
        }
        detail={
          open ? (
            <div style={{ padding: 'var(--mn-space-4)' }}>
              <RecordHeader
                headingLevel="h2"
                title={open.subject}
                status={<Badge tone="info">Open</Badge>}
                subtitle={`From ${open.from}`}
                actions={
                  <SplitInboxContextToggle
                    showLabel="Show ticket details"
                    hideLabel="Hide ticket details"
                  />
                }
              />
              <p style={{ margin: 0, fontSize: 'var(--mn-text-sm)' }}>{open.preview}</p>
            </div>
          ) : (
            <EmptyState
              size="compact"
              headingLevel="h2"
              title="Pick a conversation"
              description="Choose one from the list."
            />
          )
        }
        context={
          <div style={{ padding: 'var(--mn-space-4)' }}>
            <DescriptionList columns={1}>
              <DescriptionItem term="Requester">{open?.from}</DescriptionItem>
              <DescriptionItem term="Priority">High</DescriptionItem>
              <DescriptionItem term="Assignee">{null}</DescriptionItem>
            </DescriptionList>
          </div>
        }
      />
    </div>
  );
}

<InboxDemo />
```
