Transfer

Two lists and the movement between them, for choosing a subset from a large pool. Both sides are searchable — filtering the target is what makes a 200-item selection usable.

src/components/ui/transfer.tsx

About

From the component's own source, where it was written.

Two lists and the movement between them: choose a subset from a large pool.

**The reason this exists rather than a multi-select** is that it shows the chosen set as a *list* instead of a row of chips. Once a selection passes roughly a dozen items, chips wrap into a wall you cannot scan, cannot search and cannot sort — and the question "did I already add Maria?" stops being answerable at a glance. Permissions, mailing lists and column pickers are all this shape.

**Both sides are searchable independently.** Filtering the source is obvious; filtering the target is what makes a 200-item selection usable, and it is the half most implementations leave out.

Each side is a listbox with aria-multiselectable, and moving items is a button — not a double-click only, which is undiscoverable and impossible on a keyboard. Selection state is per-side and clears after a move, because the items are no longer where you left them.

Install

npx astralyx-ui add transfer

Usage

import { Transfer } from '@/components/ui/transfer'

<Transfer items={items} value={selected} onChange={setSelected} />

Composer

Change the props and the source below follows.

Available

0 of 9 selected

Granted

0 of 3 selected

Props
tsx
<Transfer
  items={items}
  value={selected}
  onChange={setSelected}
  searchable={true}
/>

Examples

Granting permissions

API

Generated from the source, so it cannot fall behind the code. One table per exported component, then hooks, functions and types.

Transfer

Extends Omit<ComponentProps<'div'>, 'onChange' | 'defaultValue'>.

PropTypeDefaultDescription
itemsTransferItem[]required{ value, label, keywords?, disabled? }. value holds what is on the right.
valuestring[]—{ value, label, keywords?, disabled? }. value holds what is on the right.
defaultValuestring[][]—
onChange(value: string[]) => void——
titles[ReactNode, ReactNode]['Available', 'Selected']Headings for the two panels.
searchablebooleantrueFiltering the source is obvious. Filtering the target is what most implementations leave out.
heightnumber240—
searchPlaceholderstring'Search'—
emptyLabelstring'Nothing here.'—
toTargetLabelstring'Move to selected'—
toSourceLabelstring'Move to available'—
countLabel(selected: number, total: number) => ReactNode(selected, total) => `${selected} of ${total} selected`Rendered under each title. Defaults to "n of m".
disabledboolean——

Types

TransferItem

Two lists and the movement between them: choose a subset from a large pool.

FieldTypeDescription
valuestring—
labelReactNode—
keywords?stringSearched against. Falls back to label when it is a string.
disabled?boolean—

Behaviour

TopicDetailDescription
vs MultiSelecta list, not chipsPast about a dozen, chips wrap into a wall you cannot scan, search or sort — and "did I already add Maria?" stops being answerable at a glance.
selectionclears on moveThe rows are no longer where you left them, so keeping them ticked would be misleading.