Skip to content

Early previewhamur is not on npm yet. The components are being built in the open, follow along on GitHub.

InputsReact

Transfer List

Two lists side by side: the items the user can pick from, and the items they picked.

Rendered by React 19.3.1

Loading React…
import { TransferList } from '@hamur/react/transfer-list'

const upgrades = [
  'Leather seats',
  'Front seat warmers',
  'Rear bike rack',
  'Rear seat warmers',
  'Sunroof',
  'Tow hitch',
]

function ChevronsIcon() {
  return (
    <svg
      className="transfer-list-icon"
      width="16"
      height="16"
      viewBox="0 0 24 24"
      aria-hidden="true"
    >
      <path d="M6 7l5 5-5 5M13 7l5 5-5 5" />
    </svg>
  )
}

export default function TransferListExample() {
  return (
    <div className="stack">
      <h3 id="transfer-list-upgrades" className="transfer-list-heading">
        Upgrades
      </h3>
      <TransferList.Root
        className="transfer-list"
        aria-labelledby="transfer-list-upgrades"
        items={upgrades}
      >
        <TransferList.Source className="transfer-list-side">
          <TransferList.Label className="transfer-list-label">
            Available
          </TransferList.Label>
          <TransferList.List className="transfer-list-list">
            {(item: string) => (
              <TransferList.Item
                key={item}
                className="transfer-list-item"
                value={item}
              >
                <TransferList.ItemIndicator
                  className="transfer-list-item-indicator"
                >
                  ✓
                </TransferList.ItemIndicator>
                <TransferList.ItemText className="transfer-list-item-text">
                  {item}
                </TransferList.ItemText>
              </TransferList.Item>
            )}
          </TransferList.List>
          <TransferList.Empty className="transfer-list-empty">
            Every upgrade is chosen
          </TransferList.Empty>
        </TransferList.Source>
        <div className="transfer-list-actions">
          <TransferList.Action className="button" action="moveToTarget">
            Add
          </TransferList.Action>
          <TransferList.Action
            className="button button-square"
            action="moveAllToTarget"
          >
            <ChevronsIcon />
          </TransferList.Action>
          <TransferList.Action className="button" action="moveToSource">
            Remove
          </TransferList.Action>
          <TransferList.Action
            className="button button-square transfer-list-back"
            action="moveAllToSource"
          >
            <ChevronsIcon />
          </TransferList.Action>
        </div>
        <TransferList.Target className="transfer-list-side">
          <TransferList.Label className="transfer-list-label">
            Chosen
          </TransferList.Label>
          <TransferList.List className="transfer-list-list">
            {(item: string) => (
              <TransferList.Item
                key={item}
                className="transfer-list-item"
                value={item}
              >
                <TransferList.ItemIndicator
                  className="transfer-list-item-indicator"
                >
                  ✓
                </TransferList.ItemIndicator>
                <TransferList.ItemText className="transfer-list-item-text">
                  {item}
                </TransferList.ItemText>
              </TransferList.Item>
            )}
          </TransferList.List>
          <TransferList.Empty className="transfer-list-empty">
            No upgrades chosen
          </TransferList.Empty>
        </TransferList.Target>
      </TransferList.Root>
      <p className="muted">
        Enter moves the selected upgrades, or the focused one, to the other list; Delete moves them
        back.
      </p>
    </div>
  )
}
Not measured yetTests passing in React
Not measured yetSize
Not measured yetMemory
Not measured yetSpeed
[N] commits

Installation

npm i @hamur/react

Not on npm yet. The package goes out under the chosen name.

Anatomy

Import the component and assemble its parts:

<TransferList.Root>
  <TransferList.Source>
    <TransferList.Label />
    <TransferList.List>
      <TransferList.Item>
        <TransferList.ItemIndicator />
        <TransferList.ItemText />
      </TransferList.Item>
    </TransferList.List>
    <TransferList.Empty />
  </TransferList.Source>
  <TransferList.Action />
  <TransferList.Target />
</TransferList.Root>

// Not in this example: TransferList.Filter

Examples

Filter

Office cities with a filter above each list; the move-all buttons take the shown cities only.

TransferListFilter.tsx
import { TransferList } from '@hamur/react/transfer-list'

const cities = [
  'Amsterdam',
  'Berlin',
  'Lisbon',
  'Madrid',
  'Oslo',
  'Paris',
  'Prague',
  'Rome',
  'Stockholm',
  'Vienna',
  'Warsaw',
  'Zurich',
]

function renderCity(city: string) {
  return (
    <TransferList.Item key={city} className="transfer-list-item" value={city}>
      <TransferList.ItemIndicator className="transfer-list-item-indicator">
        ✓
      </TransferList.ItemIndicator>
      <TransferList.ItemText className="transfer-list-item-text">{city}</TransferList.ItemText>
    </TransferList.Item>
  )
}

export default function TransferListFilterExample() {
  return (
    <TransferList.Root
      className="transfer-list"
      aria-label="Office cities"
      items={cities}
      defaultValue={['Lisbon']}
    >
      <TransferList.Source className="transfer-list-side">
        <TransferList.Label className="transfer-list-label">Available</TransferList.Label>
        <TransferList.Filter
          className="input transfer-list-filter"
          aria-label="Filter available"
          placeholder="Filter"
        />
        <TransferList.List className="transfer-list-list">{renderCity}</TransferList.List>
        <TransferList.Empty className="transfer-list-empty">No city to show</TransferList.Empty>
      </TransferList.Source>
      <div className="transfer-list-actions">
        <TransferList.Action className="button" action="moveToTarget">
          Add
        </TransferList.Action>
        <TransferList.Action className="button" action="moveAllToTarget">
          Add shown
        </TransferList.Action>
        <TransferList.Action className="button" action="moveToSource">
          Remove
        </TransferList.Action>
        <TransferList.Action className="button" action="moveAllToSource">
          Remove shown
        </TransferList.Action>
      </div>
      <TransferList.Target className="transfer-list-side">
        <TransferList.Label className="transfer-list-label">Chosen</TransferList.Label>
        <TransferList.Filter
          className="input transfer-list-filter"
          aria-label="Filter chosen"
          placeholder="Filter"
        />
        <TransferList.List className="transfer-list-list">{renderCity}</TransferList.List>
        <TransferList.Empty className="transfer-list-empty">No city to show</TransferList.Empty>
      </TransferList.Target>
    </TransferList.Root>
  )
}

Reorder

Table columns whose shown order the user sets with Up and Down, Alt and the arrow keys, or drag and drop.

TransferListReorder.tsx
import { TransferList } from '@hamur/react/transfer-list'

const columns = ['Name', 'Email', 'Role', 'Team', 'Location', 'Last seen']

function renderColumn(column: string) {
  return (
    <TransferList.Item key={column} className="transfer-list-item" value={column}>
      <TransferList.ItemIndicator className="transfer-list-item-indicator">
        ✓
      </TransferList.ItemIndicator>
      <TransferList.ItemText className="transfer-list-item-text">{column}</TransferList.ItemText>
    </TransferList.Item>
  )
}

function ArrowIcon() {
  return (
    <svg
      className="transfer-list-icon"
      width="16"
      height="16"
      viewBox="0 0 24 24"
      aria-hidden="true"
    >
      <path d="M12 19V5M6 11l6-6 6 6" />
    </svg>
  )
}

export default function TransferListReorderExample() {
  return (
    <div className="stack">
      <TransferList.Root
        className="transfer-list"
        aria-label="Table columns"
        items={columns}
        defaultValue={['Name', 'Email', 'Role']}
        reorderable
        allowDrag
      >
        <TransferList.Source className="transfer-list-side">
          <TransferList.Label className="transfer-list-label">Hidden</TransferList.Label>
          <TransferList.List className="transfer-list-list">{renderColumn}</TransferList.List>
          <TransferList.Empty className="transfer-list-empty">
            Every column is shown
          </TransferList.Empty>
        </TransferList.Source>
        <div className="transfer-list-actions">
          <TransferList.Action className="button" action="moveToTarget">
            Show
          </TransferList.Action>
          <TransferList.Action className="button" action="moveToSource">
            Hide
          </TransferList.Action>
        </div>
        <TransferList.Target className="transfer-list-side">
          <TransferList.Label className="transfer-list-label">Shown</TransferList.Label>
          <TransferList.List className="transfer-list-list">{renderColumn}</TransferList.List>
          <TransferList.Empty className="transfer-list-empty">
            No column is shown
          </TransferList.Empty>
        </TransferList.Target>
        <div className="transfer-list-order">
          <TransferList.Action className="button button-square" action="moveUp">
            <ArrowIcon />
          </TransferList.Action>
          <TransferList.Action
            className="button button-square transfer-list-down"
            action="moveDown"
          >
            <ArrowIcon />
          </TransferList.Action>
        </div>
      </TransferList.Root>
      <p className="muted">
        Alt+↑ and Alt+↓ move the selected columns; drag a column to the other list or to a place in
        Shown.
      </p>
    </div>
  )
}

Controlled

Reviewer objects held by the app, one disabled, moved everyone at once through actionsRef and cleared from outside.

TransferListControlled.tsx
import * as React from 'react'
import { TransferList } from '@hamur/react/transfer-list'

interface Reviewer {
  id: string
  name: string
  away?: boolean
}

const reviewers: Reviewer[] = [
  { id: 'ada', name: 'Ada Lovelace' },
  { id: 'grace', name: 'Grace Hopper' },
  { id: 'alan', name: 'Alan Turing', away: true },
  { id: 'katherine', name: 'Katherine Johnson' },
  { id: 'edsger', name: 'Edsger Dijkstra' },
]

const nameOf = (reviewer: Reviewer) => reviewer.name
const sameReviewer = (item: Reviewer, value: Reviewer) => item.id === value.id

function renderReviewer(reviewer: Reviewer) {
  return (
    <TransferList.Item key={reviewer.id} className="transfer-list-item" value={reviewer}>
      <TransferList.ItemIndicator className="transfer-list-item-indicator">
        ✓
      </TransferList.ItemIndicator>
      <TransferList.ItemText className="transfer-list-item-text">
        {reviewer.away ? `${reviewer.name} (away)` : reviewer.name}
      </TransferList.ItemText>
    </TransferList.Item>
  )
}

export default function TransferListControlledExample() {
  const [value, setValue] = React.useState<Reviewer[]>([reviewers[1]])
  const [change, setChange] = React.useState('none yet')
  const actionsRef = React.useRef<TransferList.Root.Actions>(null)
  return (
    <div className="stack">
      <TransferList.Root
        className="transfer-list"
        aria-label="Reviewers"
        items={reviewers}
        value={value}
        onValueChange={(next: Reviewer[], details: TransferList.Root.ChangeEventDetails) => {
          setValue(next)
          setChange(`${details.action} by ${details.reason}`)
        }}
        itemToStringLabel={nameOf}
        isItemEqualToValue={sameReviewer}
        isItemDisabled={(reviewer: Reviewer) => reviewer.away === true}
        actionsRef={actionsRef}
      >
        <TransferList.Source className="transfer-list-side">
          <TransferList.Label className="transfer-list-label">Team</TransferList.Label>
          <TransferList.List className="transfer-list-list">{renderReviewer}</TransferList.List>
          <TransferList.Empty className="transfer-list-empty">
            Everyone is reviewing
          </TransferList.Empty>
        </TransferList.Source>
        <div className="transfer-list-actions">
          <TransferList.Action className="button" action="moveToTarget">
            Add
          </TransferList.Action>
          <TransferList.Action className="button" action="moveToSource">
            Remove
          </TransferList.Action>
        </div>
        <TransferList.Target className="transfer-list-side">
          <TransferList.Label className="transfer-list-label">Reviewers</TransferList.Label>
          <TransferList.List className="transfer-list-list">{renderReviewer}</TransferList.List>
          <TransferList.Empty className="transfer-list-empty">No reviewer yet</TransferList.Empty>
        </TransferList.Target>
      </TransferList.Root>
      <div className="row">
        <button
          type="button"
          className="button transfer-list-everyone"
          onClick={() => actionsRef.current?.move('moveAllToTarget')}
        >
          Add everyone
        </button>
        <button type="button" className="button transfer-list-clear" onClick={() => setValue([])}>
          Clear
        </button>
      </div>
      <p className="muted transfer-list-value">
        Reviewers: {value.map(nameOf).join(', ') || 'none'}
      </p>
      <p className="muted transfer-list-change">Last change: {change}</p>
    </div>
  )
}

Form

Required pizza toppings in a Field, submitted in the chosen order and brought back by a reset.

TransferListForm.tsx
import * as React from 'react'
import { Field } from '@hamur/react/field'
import { Form } from '@hamur/react/form'
import { TransferList } from '@hamur/react/transfer-list'

const toppings = ['Cheese', 'Mushrooms', 'Olives', 'Onions', 'Peppers', 'Tomatoes']

function renderTopping(topping: string) {
  return (
    <TransferList.Item key={topping} className="transfer-list-item" value={topping}>
      <TransferList.ItemIndicator className="transfer-list-item-indicator">
        ✓
      </TransferList.ItemIndicator>
      <TransferList.ItemText className="transfer-list-item-text">{topping}</TransferList.ItemText>
    </TransferList.Item>
  )
}

export default function TransferListFormExample() {
  const [submitted, setSubmitted] = React.useState('')
  return (
    <Form
      className="stack"
      onFormSubmit={(_values, details) => {
        const form = details.event.target as HTMLFormElement
        setSubmitted([...new FormData(form)].map(([key, value]) => `${key}=${value}`).join('&'))
      }}
    >
      <Field.Root name="toppings" className="field">
        <Field.Label className="transfer-list-heading" nativeLabel={false} render={<div />}>
          Toppings
        </Field.Label>
        <TransferList.Root
          className="transfer-list"
          items={toppings}
          defaultValue={['Cheese']}
          required
        >
          <TransferList.Source className="transfer-list-side">
            <TransferList.Label className="transfer-list-label">Available</TransferList.Label>
            <TransferList.List className="transfer-list-list">{renderTopping}</TransferList.List>
            <TransferList.Empty className="transfer-list-empty">No topping left</TransferList.Empty>
          </TransferList.Source>
          <div className="transfer-list-actions">
            <TransferList.Action className="button" action="moveToTarget">
              Add
            </TransferList.Action>
            <TransferList.Action className="button" action="moveToSource">
              Remove
            </TransferList.Action>
          </div>
          <TransferList.Target className="transfer-list-side">
            <TransferList.Label className="transfer-list-label">On the pizza</TransferList.Label>
            <TransferList.List className="transfer-list-list">{renderTopping}</TransferList.List>
            <TransferList.Empty className="transfer-list-empty">
              Choose one topping or more
            </TransferList.Empty>
          </TransferList.Target>
        </TransferList.Root>
        <Field.Error className="error" />
      </Field.Root>
      <div className="row">
        <button className="button" type="submit">
          Order
        </button>
        <button className="button" type="reset" onClick={() => setSubmitted('')}>
          Reset
        </button>
      </div>
      {submitted ? <pre className="output">{submitted}</pre> : null}
    </Form>
  )
}

API reference

41 props across 11 parts, in React syntax. className, style, render are listed once, under Shared props.

ActionTransferList.Action

A button that moves items: the selection to the other List, every shown item, or the chosen selection one place up or down. While its action does not apply it is aria-disabled and keeps focus. Renders a <button>.

Action props
PropTypeDefault
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue
actionrequiredWhat the button does.TransferListActionName—
focusableWhenDisabledWhether the button stays focusable while its action does not apply.booleantrue

EmptyTransferList.Empty

Content shown while its List shows no item, on the server too. It describes the empty List and has no ARIA role. Renders a <div>.

FilterTransferList.Filter

A search field that filters its List as the user types. Down moves focus to the first shown item; Escape clears the query. Renders an <input>.

Filter props
PropTypeDefault
valueThe query.string—
defaultValueThe query when the component is initially rendered, and what a form reset brings back.string—
onValueChangeEvent handler called when the query changes.((query: string, eventDetails: TransferListFilterChangeEventDetails) => void)—

ItemTransferList.Item

An option of a List: a Listbox item whose value is one of the Root's items. Renders a <div>.

Item props
PropTypeDefault
childrenReact.ReactNode—
valuerequiredThe item this option shows: an entry of the Root's items (or of its value).any—
indexThe zero-based position of the item among the List's shown items.number—

ItemIndicatorTransferList.ItemIndicator

Indicates whether the item is selected. Renders a <span>.

ItemIndicator props
PropTypeDefault
childrenReact.ReactNode—
keepMountedWhether to keep the HTML element in the DOM when the item is not selected.booleanfalse

ItemTextTransferList.ItemText

The text of an item. Renders a <div>.

LabelTransferList.Label

The visible name of a List. Its text also names the List in the announcements and in the Actions' default names. Renders a <div>.

ListTransferList.List

The listbox of one side: a multi-select Listbox of the side's items that the Filter keeps. Renders a <div>.

List props
PropTypeDefault
selectionThe selected items of this List.readonly Item[]—
defaultSelectionThe selected items when the component is initially rendered, and what a form reset bringsreadonly Item[]—
onSelectionChangeEvent handler called when the selection of this List changes.((selection: Item[], eventDetails: ListboxRootChangeEventDetails) => void)—
orientationThe orientation of the List."horizontal" | "vertical"'vertical'
loopFocusWhether to loop keyboard focus back to the first item when the end of the List is reachedbooleantrue
virtualizedWhether the items are rendered by an external virtualizer, so only some of the side's itemsbooleanfalse
highlightedValueThe value of the highlighted item (the one that holds focus).Item | null—
onHighlightChangeEvent handler called when the highlighted item changes.((item: Item | null) => void)—
onItemHighlightedVirtualized only: called when keyboard navigation is about to move focus to the item at((itemValue: Item, eventDetails: ListboxRootHighlightEventDetails) => void)—
childrenThe items, or a function called for each item of the side that the Filter keeps, in order.React.ReactNode | ((item: Item, index: number) => React.ReactNode)—

RootTransferList.Root

Groups the two lists of a transfer list and the actions that move items between them, and owns the value: the chosen items, in their order. Renders a <div>.

Root props
PropTypeDefault
itemsrequiredEvery item of the transfer list, on either side.readonly Item[]—
valueThe chosen items, in their order.readonly Item[]—
defaultValueThe chosen items when the component is initially rendered, and what a form reset bringsreadonly Item[]—
onValueChangeEvent handler called when items are moved or the chosen items are reordered.((value: Item[], eventDetails: TransferListRootChangeEventDetails) => void)—
itemToStringLabelConverts an item to the text it is announced, filtered and matched by typeahead with.((item: Item) => string)—
itemToStringValueConverts an item to the string submitted with a form.((item: Item) => string)—
isItemEqualToValueCustom comparison logic used to determine if an item matches a value entry.((item: Item, value: Item) => boolean)—
isItemDisabledWhether an item is disabled: it cannot be selected, moved or dragged.((item: Item) => boolean)—
filterWhether an item matches a Filter's query.((item: Item, query: string) => boolean)—
reorderableWhether the chosen items can be reordered: the moveUp and moveDown Actions, Alt+Up andbooleanfalse
allowDragWhether items can be dragged between the two Lists, and within the chosen List whenbooleanfalse
moveOnDoubleClickWhether a double click on an item moves it to the other List.booleantrue
disabledWhether the component should ignore user interaction.booleanfalse
readOnlyWhether the user should be unable to select or move items.booleanfalse
requiredWhether the user must choose at least one item before submitting a form.booleanfalse
nameIdentifies the field when a form is submitted: one entry per chosen item, in order.string—
formIdentifies the form that owns the hidden inputs.string—
inputRefA ref to access the hidden input that carries required.React.Ref<HTMLInputElement>—
labelsThe texts of the announcements and of the Actions' default names.Partial<TransferListLabels>—
actionsRefA ref to imperative actions.React.Ref<TransferListRootActions>—

SourceTransferList.Source

The available side: holds the Label, the Filter and the List of the items that are not chosen. Renders a <div>.

TargetTransferList.Target

The chosen side: holds the Label, the Filter and the List of the items in the value. Renders a <div>.

Shared props

Every part that renders an element takes these.

Shared props
PropType
classNameCSS class applied to the element, or a function that returns a class based on the component's state.string | ((state: State) => string | undefined)
styleStyle applied to the element, or a function that returns a style object based on the component's state.React.CSSProperties | ((state: State) => React.CSSProperties | undefined)
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component.React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, State>

Keyboard and accessibility

Keyboard

Keyboard
KeysDoesPart
SpaceSelects or clears the focused itemItem
ShiftArrowDownExtends the selection from the last selected itemItem
ControlAMetaASelects every shown, enabled item of the list; again clears itList
EnterMoves the selected items, or the focused one, to the other list; never submits a formItem
DeleteBackspaceIn the chosen list, moves the selected items, or the focused one, backItem
AltArrowUpAltArrowDownWith reorderable, moves the selected chosen items one place; focus stays on the itemItem
ArrowDownIn a filter, moves focus to the first shown itemFilter
EscapeIn a filter with a query, clears itFilter
EnterSpaceOn a button, presses it; focus stays on the buttonAction
EscapeDuring a drag, cancels it and says soItem

Accessibility

What the parts set
PartSetsSo that
Rootrole="group", named by the Field label, a Fieldset legend, or the app's aria-labelledby or aria-label; aria-disabledthe two lists and their buttons are said as one question
Roota role="status" region as its last child, polite and atomiceach move is said once: "Sunroof moved to Chosen"
Listrole="listbox" with aria-multiselectable, named by its Label through aria-labelledby; on the chosen list aria-requiredeach list is said with its name and its rule
Listtabindex="0" and aria-describedby its Empty while it shows no itemfocus has a place after "move all", and the empty list says why
Itemrole="option" with aria-selected on every option, aria-disabled from isItemDisabledthe selected and the not selected options are both said
Filtertype="search" with aria-controls its list and no name; the app gives its aria-label or a labelthe filter is named and is never form data
Actionaria-disabled while it has nothing to move, never disabled; aria-keyshortcuts on the move buttonsfocus stays on the button after a press, and its keys are declared

Focus order

  1. Each list is one tab stop: its first selected item, else the item focused last, else the first enabled one; an empty list is a tab stop of its own.
  2. A key move keeps focus in the list, on the next item that stays, else the one before it, else the empty list.
  3. A button press keeps focus on the button. Nothing takes focus on mount or on a form reset; an invalid submit focuses the available list.