Inputs

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Lock, Search } from "lucide-react"
import { cn } from "@/lib/utils"

/** How long a refusal or a move report stays on screen before it clears itself. */
const FEEDBACK_MS = 4000
/** How long the rows that just landed keep their highlight. */
const FLASH_MS = 900
/** Height of one scroll pane when the consumer does not pick another, in px. */
const DEFAULT_LIST_HEIGHT = 232

export interface TransferItem {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/transfer-list.json

Prompt

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: value is 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-disabled rather 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 unlisteddisabled items 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.

On This Page