Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground
Available10
Assigned2

2 of 12 permissions assigned

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.

Review mattersPermissions
Available10
Assigned2

2 of 12 permissions assigned

Review does notLabels on a ticket
bug ×p1 ×infra ×

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.

Single chevron moves the ticked items; double moves everything visible.

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.

  • Name1
  • Status2
  • Region3
  • Duration4

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.

billing.read
Item idle
billing.read
Item ticked
Move enabled
Move disabled
Move all
Empty
Empty pane
Assigned9
Pane header
9 of 40 assigned
Count

Anatomy

Every part, every measurement, and the reason it is that number.

Available10
Assigned2

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.

  1. Pane widthEqual, min 14rem each

    Equal on purpose. An asymmetric pair implies one side matters more, and the whole point is that both do.

  2. Pane height224px (160px compact)

    About eight rows. Both panes share a height even when one is empty, so nothing shifts as items move.

  3. 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.

  4. FilterPer pane, optional

    Both panes get one. Filtering the assigned pane is how a user checks for a specific permission in a long list.

  5. Move column4 buttons, vertically centred

    Single chevron for the ticked items, double for everything visible. Centred so the direction is unambiguous.

  6. Row height30px, monospace where technical

    Dense, because both panes are scanned as lists. Monospace for identifiers so prefixes line up and groups become visible.

  7. Live countBelow, aria-live

    "9 of 40 assigned". The only feedback a non-visual user gets that a transfer happened.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
--space-3Gap between panes and the move column

Radius

TokenValueUsed for
--radius-lgPane corners

Typography

TokenValueUsed for
font-mono—Identifier-style items, so prefixes align

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingLabel gapTypeMin widthTouch targetWhen to use
Compact160px panes———12rem each—Inside a dialog, or beside other fields in a settings page.
Default224px panes———14rem each—The default. About eight visible rows per pane.
Tall320px panes———16rem each—A dedicated assignment screen where this is the only control.
Row30px0 8px—12px—Not a touch controlDense. Both panes are scanned rather than acted on item by item.
Move column——6px—2.5rem—Four 32px buttons, vertically centred between the panes.

Do

double-click·Enter on a focused item·the buttons
Make double-click move an itemThe move buttons are for batches. Requiring a trip to a 32px button for every single item is why this pattern is remembered as tedious.
Assigned9
Give both panes a count and a filterThe count is the only visible feedback that a move happened when the lists are long. The filter is how a user checks whether one specific item is assigned.
move() → setAssigned(...) → setPicked([])
Clear the tick marks after a transferThe ticks mean "queued to move". Leaving them set after the move means the next click on a chevron moves things the user thought they were done with.
Keep both panes the same height, alwaysA pane that shrinks when it empties moves the move buttons, which is exactly where the pointer is heading.

Don't

10 options · 2 panes · 4 buttons · 2 filters
Do not use one for ten optionsTwo panes, four buttons and two filters to choose from ten items is more interface than decision. Checkboxes show everything and move nothing.
onCheck → transfer() → no batching possible
Do not conflate ticking with movingA tick queues an item; the chevron moves it. Moving on tick removes the ability to batch, which is the only reason the pattern beats checkboxes.
Do not ship it on mobileTwo panes plus a move column on a 390px screen leaves about 150px per list. It is a desktop control, and the mobile fallback is a checkbox list.
drag only
Do not make drag the only way to reorderDrag excludes keyboard users entirely and is unreliable on touch. Up and down controls are the accessible path; drag is the enhancement.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.3.1Info and RelationshipsA2.1.1KeyboardA2.4.3Focus OrderA2.5.7Dragging MovementsAA4.1.3Status MessagesAA

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

TabMoves between the panes and the move column as whole regions, not item by item.
↑ / ↓Moves within a pane’s listbox.
SpaceTicks or unticks the focused item.
EnterTransfers the focused item immediately — the keyboard equivalent of a double-click.
Shift + ↑ / ↓Extends the tick selection, as in any multi-selectable listbox.
⌘ / Ctrl + ATicks 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.
AttributeApplied toNotes
role="listbox" aria-multiselectableEach paneWith aria-label naming the pane. Two anonymous lists side by side are indistinguishable.
role="option" aria-selectedEach itemSelected means ticked, not assigned. Which pane it is in carries the assignment.
aria-labelEach 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-disabledA move button with nothing to moveKept in the tab order so the column never changes size or position.

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
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.
searchablebooleantrueA 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".
orderablebooleanfalseAdds up and down controls to the assigned pane. Never drag-only.
heightnumber224Pane height in pixels. Both panes always share it.

Notes

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.