Desktop Patterns
What a large screen, a precise pointer and a keyboard make possible — and the accessibility debt each of them quietly runs up.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Click the list, then use ↑ ↓. Selection moves and the detail follows, but focus never leaves the list — that is what makes a queue workable at speed.
- Grace Hopper14:20p95 back under 300msThe index change landed at 14:02 and the graph settled within a minute…
- Alan Turing13:04Postmortem draftFirst pass attached. I have left the timeline section for you…
- Katherine Johnson11:47Orbital Systems renewalThey want to move to annual billing before the end of the quarter…
- Ada LovelaceYesterdayRe: Analytical Engines pilotConfirmed for Thursday. Two of their engineers will join…
The index change landed at 14:02 and the graph settled within a minute… The list on the left has not moved and has not lost its selection, which is the whole reason this layout exists. Arrow keys walk the queue without focus ever leaving it.
Split view
Two panes and a divider that can be dragged, double-clicked to reset, and moved with arrow keys when focused. The 1px line has a 9px hit area.
Command palette
One shortcut to remember, everything else searchable by name. Grouped results, keyboard hints in the footer, and the shortcut for each command shown beside it — so the palette teaches the shortcuts it replaces.
A discoverable shortcut sheet
Bound to ? — the convention every keyboard-first product shares. Shortcuts nobody can find are shortcuts nobody uses.
Density
Compact removes padding, never type size or contrast. The comfortable default is for everyone; compact is for the person who has this screen open all day.
- Grace Hopper14:20p95 back under 300msThe index change landed at 14:02 and the graph settled within a minute…
- Alan Turing13:04Postmortem draftFirst pass attached. I have left the timeline section for you…
- Katherine Johnson11:47Orbital Systems renewalThey want to move to annual billing before the end of the quarter…
- Ada LovelaceYesterdayRe: Analytical Engines pilotConfirmed for Thursday. Two of their engineers will join…
The index change landed at 14:02 and the graph settled within a minute… The list on the left has not moved and has not lost its selection, which is the whole reason this layout exists. Arrow keys walk the queue without focus ever leaving it.
- Grace Hopper14:20p95 back under 300ms
- Alan Turing13:04Postmortem draft
- Katherine Johnson11:47Orbital Systems renewal
- Ada LovelaceYesterdayRe: Analytical Engines pilot
The index change landed at 14:02 and the graph settled within a minute… The list on the left has not moved and has not lost its selection, which is the whole reason this layout exists. Arrow keys walk the queue without focus ever leaving it.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
- Grace Hopper14:20p95 back under 300msThe index change landed at 14:02 and the graph settled within a minute…
- Alan Turing13:04Postmortem draftFirst pass attached. I have left the timeline section for you…
- Katherine Johnson11:47Orbital Systems renewalThey want to move to annual billing before the end of the quarter…
- Ada LovelaceYesterdayRe: Analytical Engines pilotConfirmed for Thursday. Two of their engineers will join…
The index change landed at 14:02 and the graph settled within a minute… The list on the left has not moved and has not lost its selection, which is the whole reason this layout exists. Arrow keys walk the queue without focus ever leaving it.
Master–detail. The list owns focus and the selection; the detail is a consequence. Nothing on the right can move the left-hand pane.
- Master pane20–28rem
Below 20rem the rows truncate into uselessness; above 28rem the detail starts to suffer. It is a fixed width, not a fraction, so it does not move when the window resizes.
- Row56px comfortable / 36px compact
Compact removes the preview line and the padding. It never shrinks the type — that is how density becomes an accessibility failure.
- Selectiontinted row, not a border
A background carries further in peripheral vision than an outline, which matters when the eye is on the detail pane and the list is only being tracked.
- Focus ringon the list, not the row
The list is one tab stop with roving selection inside it. Fifty rows as fifty tab stops is what makes a queue unusable by keyboard.
- Hover actionsrevealed on hover and focus-within
Both, always. Hover alone hides them from every keyboard user and from touch entirely.
- Divider1px mark, 9px target
The visible line and the hit area are different sizes. A 1px grab target is a Fitts’s law problem you can fix for free.
- Detail headersticky, 48px
The actions for the selected item stay visible while its body scrolls. They are also the permanent home of whatever the hover actions duplicate.
- Detail wellindependent scroll
Two scroll contexts, not one. Scrolling the detail must never move the list — losing your place in the queue is the failure this layout exists to prevent.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-accent-subtle | — | Selected row |
| --ds-layer-hover | — | Hovered row |
| --ds-border | — | The divider mark |
| --ds-surface-overlay | — | Command palette |
Spacing
| Token | Value | Used for |
|---|---|---|
| master width | The list pane | |
| row height | Comfortable / compact | |
| divider target | Resize hit area |
Shadow
| Token | Value | Used for |
|---|---|---|
| --shadow-e5 | — | Palette elevation |
Typography
| Token | Value | Used for |
|---|---|---|
| --text-label | Row titles at both densities |
Motion
| Token | Value | Used for |
|---|---|---|
| duration | Selection change — never animated |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Min width | Max width | Touch target | When to use |
|---|---|---|---|---|---|---|
| Master pane | — | — | 20rem | 28rem | — | Fixed. Does not flex with the window. |
| Row, comfortable | 56px | 10px 12px | — | — | — | Sender, subject, preview. |
| Row, compact | 36px | 6px 10px | — | — | — | Sender and subject. Same type size. |
| Divider | — | — | 1px | — | 9px | Mark and target are different. |
| Palette | max 60vh | — | — | 36rem | — | Centred, ~20% from the top. |
| Palette row | 36px | 6px 8px | — | — | — | Icon, label, shortcut. |
| Split minimum | — | — | 22% | 78% | — | Clamp the drag. A 3% pane is a bug the user cannot undo. |
| Icon button | 24px (xs) | — | — | — | 32px | Hover actions. Smaller than touch, because it is not touch. |
.row:hover .actions, .row:focus-within .actionsNot a checklist to run at the end. These are the requirements the component was built from.
Contrast
- A selected row must be distinguishable from a hovered one, and both from neither. Three background steps that survive a greyscale screenshot.
- The divider needs 3:1 against both panes, which is harder than it sounds when one pane is a surface and the other is the canvas.
- Compact density must not reduce contrast. Smaller padding is fine; lighter text to "balance" it is the failure that follows.
Keyboard
| ↑ ↓ or J K | Moves selection within the list. The list is one tab stop, not one per row. |
| Enter | Acts on the selection — opens it, or moves focus into the detail deliberately. |
| Tab | Leaves the list for the next region. It does not walk the rows. |
| ⌘K or / | Opens the palette or focuses search. One of these should always work. |
| ? | Shows the shortcut sheet. |
| ← → on the divider | Resizes in steps once the separator has focus. |
| Escape | Closes the palette and returns focus where it was. |
Screen readers
- A row announces as "Grace Hopper, p95 back under 300ms, unread, selected, 1 of 4". Position in the set matters when someone is working a queue.
- Announce the detail change politely when selection moves, or a screen-reader user gets no confirmation that anything happened.
- Hover-revealed actions must be in the tab order when the row has focus. If they only appear on :hover they are unreachable, which is a plain 2.1.1 failure.
- The palette needs its result count in a live region, and each result needs its shortcut in its accessible name — not only in a visual Kbd.
- Single-key shortcuts must not fire while a text input has focus. Typing "e" into a search box must never archive anything.
Focus & touch
- The list holds focus while selection moves through it via aria-activedescendant. Focus enters the detail only on a deliberate act. The palette traps focus while open and returns it exactly where it was on close. Nothing here may remove a focus ring — on a keyboard-first screen the ring is the cursor.
- These patterns assume a precise pointer. Under `(hover: none)`, hover-revealed actions must be permanently visible or moved into a menu, the divider becomes a fixed layout, and master–detail becomes list-then-page. Do not ship a 9px drag target to a touchscreen — check the pointer, not the width.
| Attribute | Applied to | Notes |
|---|---|---|
| role="listbox" / role="option" | The master list | With aria-selected and roving tabindex. This is what makes fifty rows a single tab stop with an internal cursor. |
| aria-activedescendant | The list | Lets the list keep DOM focus while the selection moves — the mechanism behind "selection moves, focus does not". |
| role="separator" | The divider | With aria-orientation, aria-valuenow, aria-valuemin and aria-valuemax, plus tabindex so it can actually be reached. |
| role="dialog" | The command palette | Modal, labelled, focus-trapped. The input is a combobox with aria-controls pointing at the results list. |
| aria-live="polite" | The palette result count | "6 results" as the query narrows. Without it a screen-reader user is typing into silence. |
| Single-key shortcuts | J, K, E, S | WCAG 2.1.4: they must be disableable, remappable, or active only on focus — and they must never fire while a text field has focus. |
Example usage
1// Master–detail: one tab stop, roving selection, focus stays in the list.2<ul3 role="listbox"4 aria-label="Messages"5 aria-activedescendant={`row-${selected}`}6 tabIndex={0}7 onKeyDown={(e) => {8 if (e.key === 'ArrowDown') { e.preventDefault(); move(+1) }9 if (e.key === 'ArrowUp') { e.preventDefault(); move(-1) }10 if (e.key === 'Enter') detailRef.current?.focus() // deliberate only11 }}12>13 {items.map((m, i) => (14 <li id={`row-${i}`} key={m.id} role="option" aria-selected={i === selected}>15 {m.subject}16 {/* Hover AND focus-within — hover alone excludes the keyboard */}17 <span className="hidden group-hover:flex group-focus-within:flex">18 <IconButton label="Archive" icon={<Archive />} />19 </span>20 </li>21 ))}22</ul>2324// Ask about the affordance, not the width. A 1440px touchscreen25// has no hover; a narrow laptop window still has a keyboard.26const finePointer = useMediaQuery('(pointer: fine)')27const canHover = useMediaQuery('(hover: hover)')2829// Single-key shortcuts must never fire from inside a text field.30useEffect(() => {31 const onKey = (e: KeyboardEvent) => {32 const el = document.activeElement33 if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) return34 if (el instanceof HTMLElement && el.isContentEditable) return35 if (e.key === 'j') move(+1)36 if (e.key === 'e') archive()37 if (e.key === '?') setShortcutsOpen(true)38 }39 document.addEventListener('keydown', onKey)40 return () => document.removeEventListener('keydown', onKey)41}, [])CSS
/* The mark is 1px. The target is not — Fitts's law is free to fix. */
.ds-split__divider {
position: relative;
inline-size: 1px;
background: var(--ds-border);
cursor: col-resize;
}
.ds-split__divider::before {
content: '';
position: absolute;
inset-block: 0;
inset-inline: -4px; /* 9px total hit area */
}
.ds-split__divider:hover::before,
.ds-split__divider:focus-visible::before {
background: color-mix(in oklab, var(--ds-accent) 20%, transparent);
}
/* Both selectors, always. Hover alone hides these from the keyboard. */
.ds-row__actions { display: none; }
.ds-row:hover .ds-row__actions,
.ds-row:focus-within .ds-row__actions { display: flex; }
/* Density is padding. Type size and contrast do not move. */
[data-density='compact'] .ds-row { padding-block: 6px; }
[data-density='compact'] .ds-row__preview { display: none; }
/* Selection reads further in peripheral vision than an outline does. */
.ds-row[aria-selected='true'] { background: var(--ds-accent-subtle); }
/* Ask about the pointer, not the width. */
@media (hover: none) {
.ds-row__actions { display: flex; } /* nothing to reveal on */
.ds-split__divider { display: none; } /* no dragging without a pointer */
}Component API
Kbd
| Prop | Type | Default | Description |
|---|---|---|---|
| children* | ReactNode | — | One key. Compose several for a chord — each key gets its own element. |
Split view contract
| Prop | Type | Default | Description |
|---|---|---|---|
| role* | "separator" | — | On the divider, with aria-orientation. |
| aria-valuenow / min / max* | number | — | The current split as a percentage, and the clamped range. |
| tabIndex* | 0 | — | The divider must be reachable, and arrow keys must resize it. |
| onDoubleClick | () => void | — | Resets to the default ratio. The cheapest possible undo for a drag. |
Professional tips
- Pick one shortcut scheme and hold it. J/K from Vim and arrow keys can coexist; two conflicting schemes in one product cannot.
- Show the shortcut everywhere the action appears — in the menu, in the tooltip, in the palette. Repetition is how people learn them without reading anything.
- Persist density and pane sizes per user, per view. A setting that resets is worse than no setting, because the user has to keep re-making the decision.
- Right-click menus are a genuine desktop affordance, but never the only route. Every item in one belongs somewhere clickable too.
- Let the palette search content as well as commands. "Go to Postmortem draft" is what people actually want, more often than any verb.
- Preserve selection across a refresh. Coming back to a queue and finding the cursor at the top is a small betrayal that happens every day.
Performance
- Virtualise the master list past a few hundred rows, and keep the selected row rendered even when it scrolls out — otherwise the detail flickers as the list moves.
- Debounce the detail fetch on rapid arrow-keying. Someone holding ↓ should not fire forty requests on the way past.
- Prefetch the next and previous items in the queue. In a triage workflow the user goes one way, and it is nearly free to be ready.
- Drive the split drag with a CSS custom property and update it in a pointermove handler, not React state. Re-rendering both panes per frame is what makes a divider feel gluey.
- Build the palette index once and search it in memory. Anything that hits the network on every keystroke feels slower than the menu it replaced.
Common mistakes
- Focus jumping into the detail on every selection change, so arrow-keying a queue is impossible.
- Hover-only row actions, invisible to keyboard and touch alike.
- Type shrunk in the name of density.
- A command palette holding actions that exist nowhere else.
- Overriding ⌘F, ⌘W or ⌘T and breaking a browser reflex.
- Single-key shortcuts firing while the user types in a search box.
- Pane sizes and density resetting on every reload.
- A 1px drag target with no larger hit area.
Real-world recommendations
- Watch a heavy user for ten minutes. The patterns worth building are the ones they have invented a workaround for — a saved filter they re-apply, a tab they keep open.
- Keyboard support is not a power-user luxury; it is the same code path a screen-reader user depends on. Products with real shortcut support are usually the accessible ones by accident.
- The palette works because it needs one memorised fact instead of thirty. Any feature you can reduce to "one thing to remember, then search" is worth the same treatment.
- Density arguments are audience arguments. Ship both and log which one people pick — the answer is usually split down the middle, which is why picking one always upsets half the users.
- Master–detail is worth building even when a table with a modal would ship sooner. The queue-keeping-its-place property is the entire value, and a modal cannot have it.