Vue Transfer Component

Transfer

Two lists side by side and four buttons that move options 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 Vue Transfer Component is also available for Bootstrap and React. 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: <CTransfer> puts each list in multiple selection mode with the checkbox indicator, enables each button while it has something to move, and reports the values of the target list.

vue
<template>
  <CTransfer v-model:value="value" :items="items" />
</template>

<script setup>
import { ref } from 'vue'
import { CTransfer } from '@coreui/vue-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' },
]

const value = ref(['5'])
</script>

items is the whole set of options — { value, label, description?, disabled? } — and value is the part of it that sits in the target list, so the two lists are one array split by what has been chosen. v-model:value keeps it in sync; leave it out and pass defaultValue to let the component own the split, and read the moves from change, which carries the new value together with { direction, moved }.

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. The transfer 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.

“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.

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

search is passed to both list boxes, so each list gets its own search field. A hidden option leaves the list box’s navigation, and everything downstream follows: the arrows skip it, the counter drops it, and the select-all button takes only what is left on screen.

vue
<template>
  <CTransfer :items="items" search />
</template>

<script setup>
import { CTransfer } from '@coreui/vue-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' },
]
</script>

The search matches the option’s label, not its description, and searchPlaceholder is both the placeholder and the first half of each field’s accessible name.

search="external" takes the filtering away: the fields stay, nothing is hidden, and every keystroke arrives as the search event with the side it was typed in and the query — the transfer’s own version of the list box’s external search. The answer comes back as items, so fetching, debouncing and filtering are yours: the example below asks the server for the source list and filters the target where it already is, in memory.

loading covers the wait. Pass a boolean for both lists or { source, target } for one of them, and that list gets aria-busy="true" on its options element, a row with loadingLabel below its options and its empty state held back, while the options stay usable.

value outlives a change of items: a value that is still in the new set stays in the target in its own order, and a value that is gone leaves both lists. Keep the chosen items in the set you hand back and the target holds still while the source is refetched.

vue
<template>
  <CTransfer
    v-model:value="value"
    :items="items"
    :loading="loading"
    search="external"
    search-placeholder="Search users"
    source-title="Users"
    target-title="Assigned"
    @search="onSearch"
  />
</template>

<script setup>
import { computed, ref } from 'vue'
import { CTransfer } from '@coreui/vue-pro'

const catalog = new Map()
const loading = ref({ source: false })
const results = ref([])
const targetQuery = ref('')
const value = ref([])

let timer

const items = computed(() => [
  ...value.value
    .map((entry) => catalog.get(entry))
    .filter((item) => item && item.label.toLowerCase().includes(targetQuery.value)),
  ...results.value.filter((item) => !value.value.includes(item.value)),
])

const loadUsers = async (query) => {
  loading.value = { source: true }

  try {
    const response = await fetch(
      `https://apitest.coreui.io/demos/users?first_name=${query}&limit=10`
    )
    const users = await response.json()

    results.value = users.records.map((user) => {
      const item = { label: `${user.first_name} ${user.last_name}`, value: String(user.id) }
      catalog.set(item.value, item)

      return item
    })
  } finally {
    loading.value = { source: false }
  }
}

const onSearch = (side, query) => {
  if (side === 'target') {
    targetQuery.value = query.trim().toLowerCase()

    return
  }

  clearTimeout(timer)
  timer = setTimeout(() => loadUsers(query), 300)
}

loadUsers('')
</script>

One way

oneWay takes the return trip away: both buttons that move back are hidden, and a value that has been chosen stays chosen.

vue
<template>
  <CTransfer :items="items" one-way />
</template>

<script setup>
import { CTransfer } from '@coreui/vue-pro'

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

Titles

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

vue
<template>
  <CTransfer
    :items="items"
    selected-counter-text="picked"
    source-title="Team"
    target-title="On call"
  />
</template>

<script setup>
import { CTransfer } from '@coreui/vue-pro'

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

Descriptions

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

vue
<template>
  <CTransfer :items="items" />
</template>

<script setup>
import { CTransfer } from '@coreui/vue-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' },
]
</script>

Disabled items

disabled on an item takes it out of the keyboard order and out of every selection method, so it never moves — the move-all buttons leave it behind, and so do moveToTarget() and 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.

vue
<template>
  <CTransfer :items="items" />
</template>

<script setup>
import { CTransfer } from '@coreui/vue-pro'

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

Custom option content

The item slot renders the contents of an option and gets { direction, index, item } — the item as it was passed in, and which list it is being rendered into. The option itself, with its value, its selection and its checkbox, stays the transfer’s.

vue
<template>
  <CTransfer :items="items">
    <template #item="{ item }">
      <CBadge class="me-3" color="secondary">{{ item.value }}</CBadge>
      {{ item.label }}
    </template>
  </CTransfer>
</template>

<script setup>
import { CBadge, CTransfer } from '@coreui/vue-pro'

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

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 aria-hidden SVGs, 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

Everything inside a list is styled through the list box’s own variables.

API

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