Transfer List
Two lists with move selected, move all, and optional reorder. Selection model, disabled empty actions, and keyboard between panes.
- machine-coding
- interview
- react
- a11y
A transfer list looks like a form control from 2004 and still shows up because it is a state machine with sharp edges. Two panes, some items highlighted, four verbs. Candidates store selected: boolean on each item, mutate both arrays, and leave Move enabled when the highlight lives in the other pane. The model is two ordered arrays and one selected id set. Buttons disable from those facts.
Keyboard is the other half: you can Tab to a pane, move the highlight, Space to select, then activate Move. A pair of mute <div>s with click-only rows fails.
Key takeaways
left: Item[],right: Item[],selected: Set<id>. Ids are unique across both panes.- Four verbs: selected right, selected left, all right, all left.
- Disable a verb when it would move nothing.
- Move preserves relative order from the source list, then appends.
- Clear selection of ids that left their pane, or keep them selected on the destination. Pick one and say it.
Two arrays, a selected set, four verbs
Do not keep one array with side: "left" | "right". You will sort on every render and then fight insertion order. Two arrays match the UI and make “move all” a splice.
type Item = { id: string; label: string };
type TransferState = {
left: Item[];
right: Item[];
selected: Set<string>;
};
function partition(list: Item[], selected: Set<string>) {
const moving: Item[] = [];
const staying: Item[] = [];
for (const item of list) {
(selected.has(item.id) ? moving : staying).push(item);
}
return { moving, staying };
}
function moveSelected(
from: Item[],
to: Item[],
selected: Set<string>
): { from: Item[]; to: Item[]; selected: Set<string> } {
const { moving, staying } = partition(from, selected);
const movedIds = new Set(moving.map((i) => i.id));
const nextSelected = new Set(selected);
for (const id of movedIds) nextSelected.delete(id);
return { from: staying, to: [...to, ...moving], selected: nextSelected };
}
function moveAll(from: Item[], to: Item[]) {
return { from: [], to: [...to, ...from] };
}
Selection is not the same as membership. Highlighted ids can exist in left or right. A single Set is enough if ids are globally unique. If the same label can appear on both sides, you still need unique ids.
On move, I clear those ids from selected so the destination does not come in already highlighted. The other valid product choice is to keep them selected on the new side so a round-trip is one click. Interviewers accept either. They do not accept Move remaining enabled with an empty selection.
Disable rules:
| Button | Disabled when |
|---|---|
> selected to right |
no selected id in left |
< selected to left |
no selected id in right |
>> all to right |
left.length === 0 |
<< all to left |
right.length === 0 |
Problem statement
Build a transfer list: two lists (Available / Chosen) and controls to move the current selection or the entire pane. Optional reorder inside a pane. Keyboard must reach rows and actions. Empty actions disable.
Requirements
Must have
- Two lists from data
- Multi-select rows (click, Shift optional)
- Move selected both directions
- Move all both directions
- Disable no-op buttons
- Accessible names on lists and buttons
Should have
- Controlled
left/right+onChange - Keyboard: listbox pattern or checkbox group per pane
- Announce after a move (
aria-live)
Nice to have
- Reorder within a pane (buttons or drag)
- Filter / search per pane
- Counts in pane labels
Planning (5 minutes out loud)
- Two arrays + selected set, never
selectedflags on items - Four functions, one per button, all returning next state
- MVP: click to toggle select, four buttons, disable rules
- Then listbox roles or keep native checkboxes (honest and fast)
- Reorder last
Native checkboxes inside each pane are a legal shortcut. You get multi-select and names for free. A listbox is the APG dual-listbox pattern and needs roving tabindex plus aria-selected. If time is tight, checkboxes, and say you know the listbox version.
Architecture
TransferList
├── Pane (label, list, optional filter)
│ └── Row × n
├── Actions
│ ├── moveRight / moveAllRight
│ └── moveLeft / moveAllLeft
└── Pane
API
type TransferListProps = {
left: Item[];
right: Item[];
onChange: (next: { left: Item[]; right: Item[] }) => void;
leftLabel?: string;
rightLabel?: string;
};
Selection can stay internal. The parent owns membership.
Implementation sketch
function TransferList({
left,
right,
onChange,
leftLabel = "Available",
rightLabel = "Chosen",
}: TransferListProps) {
const [selected, setSelected] = useState<Set<string>>(new Set());
function toggle(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
const leftSelected = left.some((i) => selected.has(i.id));
const rightSelected = right.some((i) => selected.has(i.id));
function apply(
nextLeft: Item[],
nextRight: Item[],
nextSelected: Set<string>
) {
setSelected(nextSelected);
onChange({ left: nextLeft, right: nextRight });
}
return (
<div className="transfer">
<Pane
label={leftLabel}
items={left}
selected={selected}
onToggle={toggle}
/>
<div className="actions">
<button
type="button"
disabled={!leftSelected}
onClick={() => {
const r = moveSelected(left, right, selected);
apply(r.from, r.to, r.selected);
}}
>
Move to chosen
</button>
<button
type="button"
disabled={left.length === 0}
onClick={() => {
const r = moveAll(left, right);
apply(r.from, r.to, new Set());
}}
>
Move all to chosen
</button>
<button
type="button"
disabled={!rightSelected}
onClick={() => {
const r = moveSelected(right, left, selected);
apply(r.to, r.from, r.selected);
}}
>
Move to available
</button>
<button
type="button"
disabled={right.length === 0}
onClick={() => {
const r = moveAll(right, left);
apply(r.to, r.from, new Set());
}}
>
Move all to available
</button>
</div>
<Pane
label={rightLabel}
items={right}
selected={selected}
onToggle={toggle}
/>
</div>
);
}
function Pane({
label,
items,
selected,
onToggle,
}: {
label: string;
items: Item[];
selected: Set<string>;
onToggle: (id: string) => void;
}) {
return (
<fieldset>
<legend>{label}</legend>
<ul>
{items.map((item) => (
<li key={item.id}>
<label>
<input
type="checkbox"
checked={selected.has(item.id)}
onChange={() => onToggle(item.id)}
/>
{item.label}
</label>
</li>
))}
</ul>
</fieldset>
);
}
Tab order: left pane, action buttons, right pane. That is keyboard between panes without custom focus traps. If you switch to listboxes, each pane is one tab stop and arrows move aria-activedescendant.
Reorder: “Up” / “Down” on the focused pane’s selected item. Same as a tiny list reorder. Do not start with HTML5 drag.
Accessibility essentials
| Piece | Approach |
|---|---|
| Panes | fieldset/legend or labelled listbox |
| Rows | Checkbox + label, or role="option" + aria-selected |
| Actions | Buttons with verbs, not > alone without a name |
| Disabled | disabled on the button, not onClick no-op |
| Result | Optional live “Moved 3 items to Chosen” |
Icon-only chevrons need aria-label. Color on the selected row is not enough without the checkbox or aria-selected.
Interview scoring
| Signal | What they want |
|---|---|
| Model | Two arrays, one selected set |
| Verbs | Four functions, disable rules |
| Order | Stable relative order on move |
| Keys | Ids, not index, across moves |
| A11y | Named buttons, labelled panes, keyboard path |
| Scope | Reorder is extra, dual-listbox is named if skipped |
They will click Move with nothing selected and watch the button. They will move two items and check that a third stayed. They will ask where selection goes after the move.
Footguns
>as the accessible name- Move enabled with empty selection
- Mutating
leftwithspliceand missingonChange - Selected flags on items that travel and come back doubled
key={index}so selection paints the wrong row after a move- One listbox role wrapping both panes
Interview out-loud answer
I would keep available and chosen as two arrays and a Set of selected ids. Four verbs: move selected either way, move all either way. Buttons disable when the source has nothing to send. Move pulls selected items in existing order and appends them to the destination, then clears those ids from the set so the highlight does not silently follow. Checkboxes in a fieldset are a valid MVP; a dual listbox is the APG upgrade. Reorder is a separate index shift if they ask.
Related on this site
- Multi-Select Chips Input
- Custom Dropdown Select
- Keyboard Accessibility Checklist
- Keys in lists
- Accessible names