Transfer List
A dual listbox: two multi-selectable panes with per-side search and counts, move / move-all buttons between them, and a keyboard move that reports what travelled and where focus landed.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/transfer-list.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "TransferList" component (the classic dual
listbox). Dependencies: lucide-react for icons and a cn() class merger. No
listbox, drag or state library — both panes, the filters and the whole keyboard
model are local.
Contract
- export const TransferList = React.forwardRef<HTMLDivElement, TransferListProps>;
remaining native div props spread on the root, className merged with cn().
- TransferItem = { value: string; label: string; description?: string;
disabled?: boolean; disabledReason?: string }.
disabled means "locked in place": it can cross in neither direction, and
disabledReason is the sentence anyone who tries gets back.
- TransferListProps extends
Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange">:
items: TransferItem[] // the whole catalogue
value?: string[] // controlled: what is on the RIGHT
defaultValue?: string[] // uncontrolled seed
onValueChange?: (value: string[]) => void
sourceLabel = "Available", targetLabel = "Chosen"
searchable = true // per-side filter fields
showMoveAll = true // the two double-chevron buttons
sourceEmptyText, targetEmptyText, searchPlaceholder = "Filter…"
listHeight = 232 // px, per scroll pane
disabled = false
label = "Transfer list" // accessible name of the group
onRefuse?: (message: string) => void // fires with the exact refused sentence
- DERIVATION, and this is the whole data model: `value` is the right-hand list,
in MOVE ORDER. The left-hand list is items.filter(i => !chosen.has(i.value)),
in catalogue order. Nothing about a side is stored — there is one array, and
membership decides which pane a row is painted in.
- Local state is only: the two filter queries, the two mark sets (the listbox
selection), the two roving-focus values, one transient sentence, and the list
of values that just landed. Everything else is derived per render.
Behavior
- Normalise first: chosen = Array.from(new Set(value ?? internal)). A duplicated
value would key two rows identically and make the header counter lie, and
normalising here means every array the component emits is clean whatever it
was handed.
- Marks vs. the transfer. Clicking a row SELECTS it (aria-selected); it does not
move it. Plain click replaces the side's selection with that one row and drops
the range anchor there; Ctrl/Cmd+click toggles one row; Shift+click selects the
anchor→row range over the VISIBLE rows, locked rows excluded. Double-click
moves that single row — the one gesture that both selects and transfers.
- Filtering is per side, case-insensitive, over label + description. A move acts
on marked ∩ visible, in painted order: marks deliberately survive a query
change (search, mark, search again, mark more), but a row hidden by a filter
must never travel. Bulk moves act on what the filter leaves visible, so
"filter, then ≫" is how a whole group crosses at once.
- The move, in order:
1. movable = rows.filter(not locked); if it is empty, refuse with a
sentence and stop.
2. next = side is source ? [...chosen, ...movable] : chosen.filter(not moving).
Arrivals are APPENDED, in the order they were painted on the side they
left — the right-hand list is a history, not a re-sort, which is what
makes it usable as a column order straight away.
3. Successor, computed from THIS render's rows before the commit (afterwards
these arrays no longer describe the list that was operated on):
rendered = visible rows of the side being emptied
firstIndex = index of the first moving row
remaining = rendered without the moving rows
survivorsBefore = count of remaining rows before firstIndex
successor = remaining[clamp(survivorsBefore, 0, remaining.length-1)]
i.e. the row that takes the first vacated slot, clamped to the end when
the tail was moved. remaining empty ⇒ the successor is the first row that
just landed, on the OTHER side.
4. Commit, flash the arrivals, announce.
- Focus ownership follows the ORIGIN of the move, and this is the part that is
usually got wrong:
from a list (Enter, double-click) the focused row is the node about to
unmount, so DOM focus is moved to the successor;
from a button, focus stays on the button — it survives because it goes
aria-disabled, never natively disabled.
The successor row only exists after the commit re-renders, so the handler
writes {side, value} into a ref and a dependency-less effect consumes it once,
focusing the row or, if the other pane's filter hides it, the pane itself.
Focus never lands on <body> in any path.
- Every move announces one sentence: what moved, what was left behind because it
was locked, and where focus went ("Focus is on X in Available.") or, from a
button, how many items the side now holds. Refusals use the same channel:
nothing selected -> "Nothing is selected in <side>. Click a row, or
focus one and press Space."
selection all hidden -> "Everything selected in <side> is hidden by the
filter. Clear it, or select a visible row."
locked row -> disabledReason, or "<label> is locked and cannot be
moved."
empty / no match -> "<side> is empty." / "Nothing in <side> matches the
filter."
The sentence renders under the panes (text-destructive for refusals, muted for
reports), is mirrored into a polite live region, calls onRefuse for refusals,
and clears itself after 4s — clearing is what lets the next identical refusal
be announced again instead of being swallowed as a no-change.
- Keyboard, focus inside a pane (roving tabindex — exactly one row per pane is a
tab stop, and an empty pane keeps a tab stop of its own so it can still be
reached and moved into):
ArrowDown / ArrowUp move focus one row, clamped, no wrap
Shift + Arrow/Home/End move focus and extend the anchor→row range
Home / End first / last visible row
Space toggle the mark on the focused row
Enter move the marked rows; with nothing marked, move the
focused row (preventDefault first, or inside a form
this submits)
Ctrl/Cmd + A mark every visible unlocked row
Escape two stage: clear this side's filter, else clear this
side's marks; silent when there is nothing to undo,
so a surrounding dialog still closes on the same key
A press on a pane's own padding focuses the pane rather than a row, so the
first arrow enters the list instead of skipping its first row.
In the filter field: ArrowDown drops into the list, Escape clears the field.
Both handlers bail out on event.nativeEvent.isComposing — mid-composition
every key belongs to the IME, not to the list.
- ARIA: root is role="group" with the accessible name; each pane's scroll area is
role="listbox" aria-multiselectable="true" aria-labelledby=<the header>; rows
are role="option" divs with aria-selected and aria-disabled. Rows are divs, not
buttons: a button inside a listbox is not a valid option, and the row is
already the accessible control. Selection is signalled twice — the token fill
AND a check glyph in a permanently reserved slot — so it never rests on colour
alone. Icons are aria-hidden. The empty-state line is role="presentation" so it
stays readable text without pretending to be an option.
- disabled uses aria-disabled plus a guard in every handler, and rows keep their
roving tabIndex: the browser blurs a natively disabled node to the document
body, so a control disabling under the cursor would take the keyboard user's
place in the page with it.
- Degenerate cases: a chosen value with no item behind it (a stale id the API
dropped) is rendered dashed, labelled with the raw value and LOCKED — moving it
left would delete it outright, because the left pane can only render what
`items` contains, and silently destroying a consumer's value is worse than a
row that says why it is stuck. Duplicate values inside `items` render twice and
travel together — dedupe upstream. An empty catalogue renders two empty panes
whose buttons still answer with sentences.
- Cleanup: the report/refusal timer and the arrival-flash timer are each cleared
before every replacement and both cleared on unmount. There are no listeners,
observers or rAFs to leak — nothing floats, so nothing has to be measured.
Rendering & styling
- Semantic tokens only. Panes: bg-card + border, header and filter row separated
by border-b. Rows: text-card-foreground, hover:bg-muted, marked rows
bg-primary/15 (hover:bg-primary/25) with a text-primary check, locked rows
text-muted-foreground + a padlock, unlisted rows a dashed
border-muted-foreground/40. Just-landed rows carry ring-1 ring-primary
ring-inset for 900ms. Move buttons: border + bg-background,
hover:bg-accent/text-accent-foreground, aria-disabled:opacity-40. Focus:
ring-2 ring-ring ring-inset. Refusals: text-destructive. Merge every className
through cn(). No hex / rgb / oklch anywhere.
- Rows use focus:, not focus-visible: which row owns the keyboard is this
control's entire story, so the ring shows however focus arrived.
- Every row carries a transparent border as a placeholder for the dashed one an
unlisted row gets, so that row cannot reflow the list. Labels truncate, so a
long name never widens a pane.
- Motion is decorative only: colour transitions carry motion-reduce:transition-none
and the arrival highlight is a static ring, not an animation. With motion off
the component behaves identically.
- Layout is a three-column grid (pane / buttons / pane) that stacks under sm,
with the button column turning horizontal; the arrows keep their meaning
because their accessible names say the side, not the direction.
Customization levers
- Density and height: listHeight is the one sizing dial (232 for a form, ~150 in
a dialog); px-2 py-1.5 rows plus the optional description line set the rest.
Drop `description` from your items and the pane holds half again as many rows.
- Slots worth keeping or cutting: searchable (drop for catalogues under ~10
items), showMoveAll (drop when moving everything is not a sensible action), the
description line, the padlock affordance (only meaningful with locked items),
the status line (keep the live region even if you hide the visible copy).
- Ordering policy: arrivals append. To keep the right-hand list in catalogue
order instead, sort `next` by the index of each value in `items` inside
onValueChange — but then stop advertising the value as an order. For
drag-to-reorder within the right pane, compose with a sortable list rather than
growing this one; the pointer model is different.
- Filter: matchesQuery searches label + description; narrow it to label, widen it
to a keywords[] field, or swap includes() for a subsequence match. Lift the
query into props to drive a pane from a toolbar outside the control.
- Selection semantics: Shift ranges replace the selection from the anchor. For
additive ranges, pass additive=true when the Ctrl/Cmd modifier is also held.
- Tokens: bg-primary/15 marks read as branded; swap to bg-accent /
text-accent-foreground for a neutral read, and keep the check glyph either way
so selection is never carried by colour alone.
- Bulk policy: ≫ moves everything visible. For a "fill to a cap" variant, slice
the movable rows to the room left and keep the sentence — a silent partial
move is the confusing version.Concepts
- One array, two panes — the component stores no per-side list:
valueis the right-hand side in move order and the left-hand side is whatever the catalogue has left, so the two panes can never disagree and a controlled update can never desync them. - Marking is not moving — a click selects (
aria-selected), Space toggles, Shift ranges over the visible rows; only Enter, a double-click or a move button transfers. Separating the two is what makes “pick eleven of forty, then send them across once” a single move instead of eleven. - The successor rule — a move destroys the rows it moves, so focus goes to the row that takes the first vacated slot, clamped to the end of what is left, and to the row that just landed on the other side when the pane empties out. Focus is computed before the commit and applied after it, and never falls to
<body>. - Origin decides focus ownership — a move started inside a list re-homes focus, a move started on a button leaves focus on the button; the buttons go
aria-disabledrather than natively disabled precisely so the last move cannot blur the person who made it. - Refusal that explains itself — a locked row, an empty selection, a selection hidden by the filter and an empty pane each produce one sentence in the status line, in a polite live region and through
onRefuse; the sentence clears after four seconds so an identical second refusal is announced again. - Filtered bulk — the ≫ / ≪ pair acts on what the filter leaves visible, which turns “filter to a group, move the group” into two keystrokes; marks meanwhile survive a query change, so a selection can be assembled across several searches before it travels.
- Locked and unlisted —
disableditems never cross in either direction, and a chosen value with no item behind it is shown dashed and locked rather than dropped, so a stale id from an API cannot be silently deleted by a control that was only asked to move things.
Matrix Rating
A survey matrix: statements down the side, one shared scale across the top, one native radio group per row, a pinned scale, answered-row progress, and a stacked fallback instead of sideways scrolling.
Schema Form
A form generated from a zod object schema — one control per field type, required markers and every verdict read from that same schema, typed values on submit, and an explicit notice for shapes it will not guess at.