Transfer List
Two panes — available and selected — for assigning from a large fixed set where the result has to be reviewable as a list.
Also called Dual Listbox, Pick List, List Builder — in this system all of them are Transfer List.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Both panes get their own filter
The available pane needs search because it is long. The assigned pane needs it too — reviewing nine permissions out of forty is exactly when you want to check for one.
Transfer list or multi-select
The transfer list costs roughly ten times the vertical space. It is worth it only when the user must review the whole assignment rather than just make it.
Move all, and move none
The double chevrons move everything currently visible — including whatever the filter has narrowed to, which is what makes "assign all read permissions" a two-step operation.
When order matters
Column pickers and report builders need the assigned pane reorderable. Add up and down controls — never drag alone, which excludes keyboard and touch users.
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.
2 of 12 permissions assigned
Two panes with their own headers, counts, filters and selection, and a vertically centred column of move controls between them.
- Pane widthEqual, min 14rem each
Equal on purpose. An asymmetric pair implies one side matters more, and the whole point is that both do.
- Pane height224px (160px compact)
About eight rows. Both panes share a height even when one is empty, so nothing shifts as items move.
- HeaderSelect-all + title + count
The count is the feedback. Without it a move of one item out of forty produces no visible change at all.
- FilterPer pane, optional
Both panes get one. Filtering the assigned pane is how a user checks for a specific permission in a long list.
- Move column4 buttons, vertically centred
Single chevron for the ticked items, double for everything visible. Centred so the direction is unambiguous.
- Row height30px, monospace where technical
Dense, because both panes are scanned as lists. Monospace for identifiers so prefixes line up and groups become visible.
- Live countBelow, aria-live
"9 of 40 assigned". The only feedback a non-visual user gets that a transfer happened.
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-surface | — | Pane background |
| --ds-border-subtle | — | Pane edge, header and filter dividers |
| --ds-accent-subtle | — | Ticked row fill |
| --ds-accent | — | Ticked checkbox |
| --ds-layer-hover | — | Row hover |
| --ds-fg-secondary | — | Row labels |
| --ds-fg-muted | — | Counts and empty states |
| --ds-fg-disabled | — | A move button with nothing to move |
Spacing
| Token | Value | Used for |
|---|---|---|
| --space-3 | Gap between panes and the move column |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-lg | Pane corners |
Typography
| Token | Value | Used for |
|---|---|---|
| font-mono | — | Identifier-style items, so prefixes align |
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 | Label gap | Type | Min width | Touch target | When to use |
|---|---|---|---|---|---|---|---|
| Compact | 160px panes | — | — | — | 12rem each | — | Inside a dialog, or beside other fields in a settings page. |
| Default | 224px panes | — | — | — | 14rem each | — | The default. About eight visible rows per pane. |
| Tall | 320px panes | — | — | — | 16rem each | — | A dedicated assignment screen where this is the only control. |
| Row | 30px | 0 8px | — | 12px | — | Not a touch control | Dense. Both panes are scanned rather than acted on item by item. |
| Move column | — | — | 6px | — | 2.5rem | — | Four 32px buttons, vertically centred between the panes. |
move() → setAssigned(...) → setPicked([])Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- A ticked row must differ from a hovered row by more than a tint — at a 30px row height a faint wash is invisible in daylight.
- Move buttons must reach 3:1 when enabled. Disabled ones may use the disabled tone, since their state is also carried by aria-disabled.
- Pane counts are content and owe 4.5:1 — they are the primary feedback that a transfer happened.
- The empty-pane message owes 4.5:1; it is the only thing distinguishing "nothing assigned" from a rendering failure.
Keyboard
| Tab | Moves between the panes and the move column as whole regions, not item by item. |
| ↑ / ↓ | Moves within a pane’s listbox. |
| Space | Ticks or unticks the focused item. |
| Enter | Transfers the focused item immediately — the keyboard equivalent of a double-click. |
| Shift + ↑ / ↓ | Extends the tick selection, as in any multi-selectable listbox. |
| ⌘ / Ctrl + A | Ticks everything visible in the focused pane, respecting the filter. |
Screen readers
- Announce the result of every transfer: "3 permissions assigned, 9 of 40 assigned".
- Each pane announces its size on entry: "Available, listbox, 31 items".
- When a filter is applied, announce the new count — otherwise a user arrowing through a filtered pane has no idea items are hidden.
Focus & touch
- After a transfer, focus stays on the move button so a user can transfer again immediately. If the pane the items came from is now empty, move focus to the other pane rather than leaving it on a control that has become disabled.
- This is a desktop control and should be replaced below about 768px, not squeezed. Two panes and a move column on a phone leave roughly 150px per list, which is unusable. The fallback is a filtered checkbox list with a count — same data, same outcome, one column.
| Attribute | Applied to | Notes |
|---|---|---|
| role="listbox" aria-multiselectable | Each pane | With aria-label naming the pane. Two anonymous lists side by side are indistinguishable. |
| role="option" aria-selected | Each item | Selected means ticked, not assigned. Which pane it is in carries the assignment. |
| aria-label | Each move button | "Assign 3 selected", not "Move right". Direction is meaningless without knowing which pane is which. |
| aria-live="polite" | The summary count | "9 of 40 assigned". The only feedback a screen-reader user gets that a transfer landed. |
| aria-disabled | A move button with nothing to move | Kept in the tab order so the column never changes size or position. |
Example usage
1import { TransferList } from '@/ui/Input'23<TransferList4 options={permissions}5 value={assigned}6 onChange={setAssigned}7 searchable8 labels={{ available: 'Available', selected: 'Assigned' }}9/>1011// Ticking queues; the chevron moves. Conflating them removes batching,12// which is the only reason this beats a checkbox list.13const [ticked, setTicked] = React.useState<string[]>([])1415function assign() {16 onChange([...value, ...ticked])17 setTicked([]) // ticks mean "queued" — clear them18}1920// The keyboard equivalent of a double-click. Without it, every single item21// costs a trip to a 32px button.22function onItemKeyDown(e: React.KeyboardEvent, id: string) {23 if (e.key === 'Enter') {24 e.preventDefault()25 transfer([id])26 }27 if (e.key === ' ') {28 e.preventDefault()29 toggleTick(id)30 }31}3233// "Move all" respects the filter, which is what makes "assign every read34// permission" a two-step operation instead of thirty clicks.35function assignAllVisible() {36 const visible = available.filter((i) => matches(i, query))37 onChange([...value, ...visible])38}3940// Below 768px this control does not fit. Swap it, do not squeeze it.41const narrow = useMediaQuery('(max-width: 768px)')42if (narrow) return <CheckboxList options={options} value={value} onChange={onChange} />Framework-free HTML
<div class="ds-transfer">
<div class="ds-transfer__pane">
<div class="ds-transfer__head">
<input type="checkbox" aria-label="Select all in Available" />
<span>Available</span>
<span>31</span>
</div>
<ul role="listbox" aria-multiselectable="true" aria-label="Available">
<!-- selected = ticked, NOT assigned. The pane carries the assignment. -->
<li role="option" aria-selected="true" tabindex="0">deployments.write</li>
<li role="option" aria-selected="false" tabindex="-1">billing.read</li>
</ul>
</div>
<!-- Direction is meaningless on its own: name the pane and the count. -->
<div class="ds-transfer__controls">
<button type="button" aria-label="Assign 3 selected">›</button>
<button type="button" aria-label="Assign all">»</button>
<button type="button" aria-label="Remove all">«</button>
<button type="button" aria-label="Remove 0 selected" aria-disabled="true">‹</button>
</div>
<div class="ds-transfer__pane">…</div>
</div>
<p role="status" aria-live="polite">9 of 40 permissions assigned</p>CSS
.ds-transfer {
display: flex;
align-items: stretch;
gap: 12px;
}
.ds-transfer__pane {
flex: 1 1 0;
min-inline-size: 14rem; /* equal: neither side matters more */
display: flex;
flex-direction: column;
border: 1px solid var(--ds-border-subtle);
border-radius: var(--radius-lg);
background: var(--ds-surface);
overflow: hidden;
}
/* Both panes share a height even when one is empty, so the move buttons
never shift under the pointer heading for them. */
.ds-transfer__pane ul {
flex: 1;
block-size: 224px;
overflow-y: auto;
padding: 4px;
}
.ds-transfer__controls {
display: flex;
flex-direction: column;
justify-content: center; /* centred: direction stays unambiguous */
gap: 6px;
flex: 0 0 auto;
}
[role='option'] {
display: flex;
align-items: center;
gap: 10px;
block-size: 30px;
padding-inline: 8px;
border-radius: var(--radius-md);
/* Identifiers align on their prefixes, which makes groups visible. */
font-family: var(--font-mono);
font-size: 12px;
}
[role='option'][aria-selected='true'] {
background: var(--ds-accent-subtle);
color: var(--ds-fg);
}
/* Two panes plus a move column leaves ~150px per list on a phone. Replace
the control rather than compressing it. */
@media (max-width: 768px) {
.ds-transfer { display: none; }
.ds-transfer-fallback { display: block; }
}Component API
TransferList
| Prop | Type | Default | Description |
|---|---|---|---|
| options* | Option[] | — | The full fixed set. Both panes are derived from this and the value. |
| value* | string[] | — | The assigned ids. Everything else is available. |
| onChange* | (v: string[]) => void | — | Fires after a transfer, never on a tick. |
| searchable | boolean | true | A filter per pane. The assigned pane needs one as much as the available pane does. |
| labels | { available: string; selected: string } | — | Name both panes. "Available" and "Assigned" beats "From" and "To". |
| orderable | boolean | false | Adds up and down controls to the assigned pane. Never drag-only. |
| height | number | 224 | Pane height in pixels. Both panes always share it. |
Professional tips
- Group items with headers in the available pane when the set has natural prefixes. "deployments.*" as a group makes forty permissions read as six decisions.
- Sort the assigned pane the same way as the available pane. Reordering by assignment time makes it impossible to check whether something specific is in the list.
- Show a diff summary on submit — "adding 3, removing 1" — for anything security-related. It converts a list into a decision the user can confirm.
- Persist the filter text while items move. Clearing it after every transfer makes "assign all the read permissions" needlessly painful.
- If most users end up with nearly everything assigned, invert the control: start with everything assigned and let them remove.
Performance
- Virtualise both panes past roughly 200 items, and add aria-setsize and aria-posinset when you do.
- Keep the assigned set in a Set for membership checks. An includes() per item per render is quadratic and shows at a few hundred options.
- Derive the available pane rather than storing it. Two arrays that must stay complementary will eventually disagree.
- Do not animate items between panes. The lists reflow and the animation lands on the wrong rows the moment a filter is active.
Common mistakes
- Moving items on tick, which removes the ability to batch.
- Leaving ticks set after a transfer, so the next chevron press moves the wrong things.
- Panes that resize as they empty, shifting the move buttons under the pointer.
- Move buttons labelled "Move right", which means nothing without knowing which pane is which.
- No live count, so a screen-reader user has no feedback that anything moved.
- A filter on the available pane only, making the assigned list impossible to search.
- Shipping it below 768px, where two panes cannot fit.
- Drag-only reordering, which excludes keyboard users entirely.
Real-world recommendations
- Permission assignment is the pattern’s strongest case: the omissions are as consequential as the inclusions, and both panes visible is exactly what an auditor wants.
- Users consistently miss the move buttons on first use. Double-click and Enter are what make the control learnable — instrument them and you will find they carry most of the traffic.
- Column pickers for tables and reports are the other durable use, and they are the case that needs ordering in the assigned pane.
- If your available pane routinely holds more than a hundred items, the filter is the real interface and the two panes are just presentation. Consider a searchable multi-select with a review step instead.