React Transfer Component

Transfer

Two lists side by side and four buttons that move values between them — the selection or everything at once: a select-all header with a counter, an optional per-list search, a one-way mode and an announcement of every move.

Available in Other JavaScript Frameworks

CoreUI React Transfer Component is also available for Bootstrap and Vue. Explore framework-specific implementations below:

Overview

Added in v6.0.0

A transfer moves values between two lists — what is available on the left, what has been chosen on the right. It is two list boxes and a column of buttons: each list runs in multiple selection mode with the checkbox indicator, each button is enabled while it has something to move, and a move takes the values from one list to the other.

import React from 'react'
import { CTransfer } from '@coreui/react-pro'

const items = [
  { label: 'List item 1', value: '1' },
  { label: 'List item 2', value: '2' },
  { label: 'List item 3', value: '3' },
  { label: 'List item 4', value: '4' },
  { label: 'List item 5', value: '5' },
]

export const TransferExample = () => {
  return <CTransfer defaultValue={['5']} items={items} />
}
import React from 'react'
import { CTransfer } from '@coreui/react-pro'

const items = [
  { label: 'List item 1', value: '1' },
  { label: 'List item 2', value: '2' },
  { label: 'List item 3', value: '3' },
  { label: 'List item 4', value: '4' },
  { label: 'List item 5', value: '5' },
]

export const TransferExample = () => {
  return <CTransfer defaultValue={['5']} items={items} />
}

Everything inside a list is the list box’s: the options and their values, the selection, the arrow keys, the typeahead and the select-all button. CTransfer owns what happens between the lists, so nothing here is a second implementation of the same behavior — read the List box page for the parts a list is made of.

items is what both lists are made of, each entry a value plus a label and, if you want them, a description and disabled. The target list holds the values of value / defaultValue, in the order they were chosen; the source list holds the rest, in the order items gives them.

“All” is the same scope the select-all button works on — the visible, enabled options — so with a search running, the all button moves what the search left behind and nothing more.

CTransfer is a renderer over useTransfer(), which owns the two lists, the two selections, the two queries and the moves. Call the hook directly to build a surface of your own.

The transfer caps how tall a list gets — --cui-transfer-list-max-height puts a max-height and overflow-y: auto on the options, so a long list scrolls inside its own box instead of stretching the row.

Controlled value

value is the target list, so the transfer is controlled by the values it has chosen. onChange reports the next value and what the move was — direction is the list the options landed in, moved the values that crossed. Leave value out and pass defaultValue for the uncontrolled mode.

import React, { useState } from 'react'
import { CTransfer } from '@coreui/react-pro'

const items = [
  { label: 'Ada', value: 'ada' },
  { label: 'Grace', value: 'grace' },
  { label: 'Linus', value: 'linus' },
]

export const TransferControlledExample = () => {
  const [value, setValue] = useState<string[]>(['grace'])

  return (
    <>
      <CTransfer items={items} onChange={(next) => setValue(next)} value={value} />
      <div className="mt-4">On call: {value.join(', ') || 'nobody'}</div>
    </>
  )
}
import React, { useState } from 'react'
import { CTransfer } from '@coreui/react-pro'

const items = [
  { label: 'Ada', value: 'ada' },
  { label: 'Grace', value: 'grace' },
  { label: 'Linus', value: 'linus' },
]

export const TransferControlledExample = () => {
  const [value, setValue] = useState(['grace'])

  return (
    <>
      <CTransfer items={items} onChange={(next) => setValue(next)} value={value} />
      <div className="mt-4">On call: {value.join(', ') || 'nobody'}</div>
    </>
  )
}

search is the list box’s own search, put above each of the two lists: it hides the options that do not match, which takes them out of the navigation, out of the counter and out of the select-all scope, while the selection survives.

import React from 'react'
import { CTransfer } from '@coreui/react-pro'

const items = [
  { label: 'Apple', value: 'apple' },
  { label: 'Apricot', value: 'apricot' },
  { label: 'Banana', value: 'banana' },
  { label: 'Blueberry', value: 'blueberry' },
  { label: 'Cherry', value: 'cherry' },
  { label: 'Cranberry', value: 'cranberry' },
]

export const TransferSearchExample = () => {
  return <CTransfer items={items} search />
}
import React from 'react'
import { CTransfer } from '@coreui/react-pro'

const items = [
  { label: 'Apple', value: 'apple' },
  { label: 'Apricot', value: 'apricot' },
  { label: 'Banana', value: 'banana' },
  { label: 'Blueberry', value: 'blueberry' },
  { label: 'Cherry', value: 'cherry' },
  { label: 'Cranberry', value: 'cranberry' },
]

export const TransferSearchExample = () => {
  return <CTransfer items={items} search />
}

The search matches an option’s label, or its textValue when it carries one — the description is left out of the match. searchPlaceholder is the placeholder of both fields and the first half of each field’s accessible name, and onSearch reports every query with the list it belongs to, so the two fields can be watched from one callback.

search="external" filters nothing: the lists show whatever items holds, and the query is only reported through onSearch — the fetching and the filtering are yours, as on the list box. value outlives a change of items: a value still in the new set stays in the target in its own order, and a value that’s gone leaves both lists. Keep the chosen items in the set you hand back and the target holds still while the source is refetched.

loading covers the wait. Pass true to mark both lists, or { source: true } / { target: true } to mark the one that is waiting: its options are marked aria-busy and a row with loadingLabel shows below them, while they stay usable.

import React, { useEffect, useRef, useState } from 'react'
import { CTransfer, useDebouncedCallback } from '@coreui/react-pro'
import type { TransferDirection, TransferItem } from '@coreui/react-pro'

type UserRecord = {
  first_name: string
  id: number
  last_name: string
}

export const TransferExternalSearchExample = () => {
  const [items, setItems] = useState<TransferItem[]>([])
  const [loading, setLoading] = useState(false)
  const [query, setQuery] = useState('')
  const [value, setValue] = useState<string[]>([])
  const chosen = useRef<string[]>([])

  const handleSearch = useDebouncedCallback((side: TransferDirection, next: string) => {
    if (side === 'source') {
      setQuery(next)
    }
  }, 300)

  useEffect(() => {
    const getUsers = async () => {
      setLoading(true)
      try {
        const response = await fetch(
          `https://apitest.coreui.io/demos/users?first_name=${query}&limit=20`
        )
        const result = await response.json()
        const records: TransferItem[] = result.records.map((record: UserRecord) => ({
          label: `${record.first_name} ${record.last_name}`,
          value: String(record.id),
        }))

        setItems((previous) => [
          ...previous.filter((item) => chosen.current.includes(item.value)),
          ...records.filter((item) => !chosen.current.includes(item.value)),
        ])
      } catch (error) {
        console.error('Error fetching users:', error)
        setItems((previous) => previous.filter((item) => chosen.current.includes(item.value)))
      } finally {
        setLoading(false)
      }
    }

    getUsers()
  }, [query])

  return (
    <CTransfer
      items={items}
      loading={{ source: loading }}
      onChange={(next) => {
        chosen.current = next
        setValue(next)
      }}
      onSearch={handleSearch}
      search="external"
      searchPlaceholder="Search users..."
      sourceTitle="Users"
      targetTitle="Invited"
      value={value}
    />
  )
}
import React, { useEffect, useRef, useState } from 'react'
import { CTransfer, useDebouncedCallback } from '@coreui/react-pro'

export const TransferExternalSearchExample = () => {
  const [items, setItems] = useState([])
  const [loading, setLoading] = useState(false)
  const [query, setQuery] = useState('')
  const [value, setValue] = useState([])
  const chosen = useRef([])

  const handleSearch = useDebouncedCallback((side, next) => {
    if (side === 'source') {
      setQuery(next)
    }
  }, 300)

  useEffect(() => {
    const getUsers = async () => {
      setLoading(true)
      try {
        const response = await fetch(
          `https://apitest.coreui.io/demos/users?first_name=${query}&limit=20`
        )
        const result = await response.json()
        const records = result.records.map((record) => ({
          label: `${record.first_name} ${record.last_name}`,
          value: String(record.id),
        }))

        setItems((previous) => [
          ...previous.filter((item) => chosen.current.includes(item.value)),
          ...records.filter((item) => !chosen.current.includes(item.value)),
        ])
      } catch (error) {
        console.error('Error fetching users:', error)
        setItems((previous) => previous.filter((item) => chosen.current.includes(item.value)))
      } finally {
        setLoading(false)
      }
    }

    getUsers()
  }, [query])

  return (
    <CTransfer
      items={items}
      loading={{ source: loading }}
      onChange={(next) => {
        chosen.current = next
        setValue(next)
      }}
      onSearch={handleSearch}
      search="external"
      searchPlaceholder="Search users..."
      sourceTitle="Users"
      targetTitle="Invited"
      value={value}
    />
  )
}

One way

oneWay takes the return trip away: both buttons that move back are gone, moveToSource() and moveAllToSource() refuse, and a value that has been chosen stays chosen.

import React from 'react'
import { CTransfer } from '@coreui/react-pro'

const items = [
  { label: 'List item 1', value: '1' },
  { label: 'List item 2', value: '2' },
  { label: 'List item 3', value: '3' },
]

export const TransferOneWayExample = () => {
  return <CTransfer items={items} oneWay />
}
import React from 'react'
import { CTransfer } from '@coreui/react-pro'

const items = [
  { label: 'List item 1', value: '1' },
  { label: 'List item 2', value: '2' },
  { label: 'List item 3', value: '3' },
]

export const TransferOneWayExample = () => {
  return <CTransfer items={items} oneWay />
}

Titles

sourceTitle and targetTitle name the two lists. The title is the label of the list’s select-all button, its accessible name, and the destination named in the move announcement. selectedLabel builds each list’s counter text — a function (count, total) => string, or a string in which {count} and {total} are replaced.

import React from 'react'
import { CTransfer } from '@coreui/react-pro'

const items = [
  { label: 'Ada', value: 'ada' },
  { label: 'Grace', value: 'grace' },
  { label: 'Linus', value: 'linus' },
]

export const TransferTitlesExample = () => {
  return (
    <CTransfer
      items={items}
      selectedLabel={(count, total) => `${count}/${total} picked`}
      sourceTitle="Team"
      targetTitle="On call"
    />
  )
}
import React from 'react'
import { CTransfer } from '@coreui/react-pro'

const items = [
  { label: 'Ada', value: 'ada' },
  { label: 'Grace', value: 'grace' },
  { label: 'Linus', value: 'linus' },
]

export const TransferTitlesExample = () => {
  return (
    <CTransfer
      items={items}
      selectedCounterText="picked"
      sourceTitle="Team"
      targetTitle="On call"
    />
  )
}

Descriptions

An option is the list box’s, so description adds a second line here too — and the search matches on the label, not on the description.

import React from 'react'
import { CTransfer } from '@coreui/react-pro'

const items = [
  { description: 'See the contents of a file', label: 'Read', value: 'read' },
  { description: 'Change the contents of a file', label: 'Write', value: 'write' },
  { description: 'Give someone else access', label: 'Share', value: 'share' },
]

export const TransferDescriptionsExample = () => {
  return <CTransfer items={items} />
}
import React from 'react'
import { CTransfer } from '@coreui/react-pro'

const items = [
  { description: 'See the contents of a file', label: 'Read', value: 'read' },
  { description: 'Change the contents of a file', label: 'Write', value: 'write' },
  { description: 'Give someone else access', label: 'Share', value: 'share' },
]

export const TransferDescriptionsExample = () => {
  return <CTransfer items={items} />
}

Pass renderItem to render an option yourself. It takes the option and its index, and the option it hands you carries the hidden the search left behind — pass it on, or the filter stops showing.

Disabled items

disabled on an entry of items takes the option out of the keyboard order and out of every selection method, so it never moves: not through a move button, and not through moveToTarget() or moveToSource() with the value in hand. disabled on the transfer does the same for all of it: both lists go quiet and every button is disabled.

import React from 'react'
import { CTransfer } from '@coreui/react-pro'

const items = [
  { label: 'List item 1', value: '1' },
  { disabled: true, label: 'List item 2 — locked', value: '2' },
  { label: 'List item 3', value: '3' },
]

export const TransferDisabledExample = () => {
  return <CTransfer items={items} />
}
import React from 'react'
import { CTransfer } from '@coreui/react-pro'

const items = [
  { label: 'List item 1', value: '1' },
  { disabled: true, label: 'List item 2 — locked', value: '2' },
  { label: 'List item 3', value: '3' },
]

export const TransferDisabledExample = () => {
  return <CTransfer items={items} />
}

Accessibility

Each list is a listbox of its own with the title as its accessible name, and the select-all button carries the state of its list in aria-pressed — all of that comes from List box, keyboard included: the arrows, Home, End, Space, Shift ranges, Ctrl + A and the typeahead all work inside the list that has the focus.

What the transfer adds is the crossing. Each move button is named — ariaMoveToTargetLabel, ariaMoveToSourceLabel, ariaMoveAllToTargetLabel, ariaMoveAllToSourceLabel — and is disabled while it has nothing to move, so the keyboard never lands on a button that would do nothing. The chevrons are decorative, so the name is all a screen reader gets.

The move happens away from the focus, and the button that did it usually turns off in the process — a list with nothing selected left. Rather than dropping the focus on the document, the component puts it on the first option that just arrived.

Every move, either way, adds a message of its own to the live region of the page through announce() once it is applied, so a move that a controlled value refuses is not announced and the same move made twice is announced twice: ariaMovedAnnouncement is '{count} moved to {title}' by default, with {title} the destination’s title. {count} and {title} are replaced wherever they appear, so the sentence can be reordered for a translation.

KeyAction
TabMove between the select-all button, the search field, the options and the move buttons
↓ / ↑Move the highlight inside the focused list
SpaceToggle the highlighted option
Shift + ↓ / ↑Extend the selection inside the focused list
Ctrl / Cmd + ASelect every visible, enabled option of the focused list
Enter / SpaceMove the selection when the focus is on a move button

Customizing

CSS variables

The transfer reads its own set of CSS variables, declared on .transfer. Set them on a single transfer to restyle it without recompiling:

--cui-transfer-gap
--cui-transfer-actions-gap
--cui-transfer-list-min-height
--cui-transfer-list-max-height

The lists carry the list box variables on top of these, so the padding, the colors and the option states are set there.

API

Check out the documentation below for a comprehensive guide to all the props you can use with the components mentioned here.