Transfer
Dual-panel list for moving items between source and target collections.
<script setup lang="ts">
import { ref } from 'vue'
import { VxTransfer } from 'veloxui'
const data = Array.from({ length: 12 }, (_, i) => ({
key: i + 1,
label: `Item ${i + 1}`,
disabled: i === 3 || i === 7,
}))
const value = ref([2, 5])
</script>
<template>
<VxTransfer v-model="value" :data="data" />
</template>Filterable
<script setup lang="ts">
import { ref } from 'vue'
const data = Array.from({ length: 12 }, (_, i) => ({
key: i + 1,
label: `Item ${i + 1}`,
}))
const value = ref([1, 4])
</script>
<template>
<VxTransfer v-model="value" :data="data" filterable />
</template>Custom Titles
<script setup lang="ts">
import { ref } from 'vue'
const cities = [
{ key: 'nyc', label: 'New York' },
{ key: 'la', label: 'Los Angeles' },
{ key: 'chi', label: 'Chicago' },
{ key: 'hou', label: 'Houston' },
{ key: 'phx', label: 'Phoenix' },
]
const value = ref(['nyc', 'chi'])
</script>
<template>
<VxTransfer
v-model="value"
:data="cities"
:titles="['Available Cities', 'Selected Cities']"
filterable
/>
</template>Field Mapping
Use key-field and label-field to map custom data properties without transforming your data.
<script setup lang="ts">
import { ref } from 'vue'
const users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 3, name: 'Charlie' },
{ id: 4, name: 'Diana' },
{ id: 5, name: 'Eve' },
]
const value = ref([2])
</script>
<template>
<VxTransfer
v-model="value"
:data="users"
key-field="id"
label-field="name"
:titles="['Users', 'Team']"
/>
</template>Panel Width
<script setup lang="ts">
import { ref } from 'vue'
const data = Array.from({ length: 6 }, (_, i) => ({
key: i + 1,
label: `Item ${i + 1}`,
}))
const value = ref([1, 3])
</script>
<template>
<!-- Number = pixels -->
<VxTransfer v-model="value" :data="data" :panel-width="300" />
<!-- String = any CSS value -->
<VxTransfer v-model="value" :data="data" panel-width="50%" />
</template>Sort
Items in both panels are sorted using the provided comparator function.
<script setup lang="ts">
import { ref } from 'vue'
const data = Array.from({ length: 12 }, (_, i) => ({
key: i + 1,
label: `Item ${i + 1}`,
}))
const value = ref([3, 1])
</script>
<template>
<VxTransfer
v-model="value"
:data="data"
:sort-method="(a, b) => a.label.localeCompare(b.label)"
:titles="['Sorted A-Z', 'Sorted A-Z']"
/>
</template>Move All
Show move-all buttons (double arrows) to transfer all enabled items at once.
<script setup lang="ts">
import { ref } from 'vue'
const data = Array.from({ length: 12 }, (_, i) => ({
key: i + 1,
label: `Item ${i + 1}`,
disabled: i === 3 || i === 7,
}))
const value = ref([])
</script>
<template>
<VxTransfer v-model="value" :data="data" show-move-all />
</template>Button Texts
<script setup lang="ts">
import { ref } from 'vue'
const data = Array.from({ length: 12 }, (_, i) => ({
key: i + 1,
label: `Item ${i + 1}`,
}))
const value = ref([])
</script>
<template>
<VxTransfer v-model="value" :data="data" :button-texts="['Add', 'Remove']" />
</template>Custom Button Slots
Each slot receives move, disabled, and checkedCount.
<script setup lang="ts">
import { ref } from 'vue'
const data = Array.from({ length: 8 }, (_, i) => ({
key: i + 1,
label: `Item ${i + 1}`,
}))
const value = ref([4])
</script>
<template>
<VxTransfer v-model="value" :data="data">
<template #to-target-button="{ move, disabled, checkedCount }">
<button :disabled="disabled" @click="move">
Add {{ checkedCount || '' }} →
</button>
</template>
<template #to-source-button="{ move, disabled, checkedCount }">
<button :disabled="disabled" @click="move">
← Remove {{ checkedCount || '' }}
</button>
</template>
</VxTransfer>
</template>Custom Item Slot
<script setup lang="ts">
import { ref } from 'vue'
const data = Array.from({ length: 12 }, (_, i) => ({
key: i + 1,
label: `Item ${i + 1}`,
}))
const value = ref([3])
</script>
<template>
<VxTransfer v-model="value" :data="data">
<template #item="{ item }">
<span>{{ item.label }}</span>
<span style="margin-left: auto; font-size: 11px; opacity: 0.5">
#{{ item.key }}
</span>
</template>
</VxTransfer>
</template>Header & Footer Slots
Customize panel headers and footers. Slots receive { checked, total, query }.
<script setup lang="ts">
import { ref } from 'vue'
const data = Array.from({ length: 6 }, (_, i) => ({
key: i + 1,
label: `Item ${i + 1}`,
}))
const value = ref([])
</script>
<template>
<VxTransfer v-model="value" :data="data">
<template #source-header="{ total }">
<div class="vx-transfer__header" style="background: var(--muted);">
<span style="font-size: 13px; font-weight: 600;">
Pick items ({{ total }})
</span>
</div>
</template>
<template #target-footer="{ total }">
<div class="vx-transfer__footer">
<span class="vx-transfer__footer-info">
{{ total }} selected
</span>
</div>
</template>
</VxTransfer>
</template>Drag and Drop
Drag items between panels. Enable with draggable. The target panel highlights when dragging over it.
<script setup lang="ts">
import { ref } from 'vue'
const data = Array.from({ length: 12 }, (_, i) => ({
key: i + 1,
label: `Item ${i + 1}`,
disabled: i === 3 || i === 7,
}))
const value = ref([2, 5])
</script>
<template>
<VxTransfer v-model="value" :data="data" draggable />
</template>Keyboard Navigation
Click a panel body to focus it, then use Arrow Up/Down to navigate items and Space or Enter to toggle selection. The focused item gets an outline ring.
<script setup lang="ts">
import { ref } from 'vue'
const data = Array.from({ length: 12 }, (_, i) => ({
key: i + 1,
label: `Item ${i + 1}`,
}))
const value = ref([])
</script>
<template>
<!-- Click inside a panel, then use Arrow keys + Space/Enter -->
<VxTransfer v-model="value" :data="data" />
</template>Scroll Pagination
Set page-size to load items incrementally as the user scrolls. Only the visible page renders — more items appear automatically when scrolling near the bottom.
<script setup lang="ts">
import { ref } from 'vue'
const bigData = Array.from({ length: 100 }, (_, i) => ({
key: i + 1,
label: `Record ${i + 1}`,
disabled: i === 9,
}))
const value = ref([5, 10, 20, 30, 40])
</script>
<template>
<VxTransfer
v-model="value"
:data="bigData"
:page-size="15"
filterable
:titles="['All Records (100)', 'Selected']"
/>
</template>Virtual Scroll
For massive datasets (1000+ items), enable virtual-scroll with item-height for smooth 60fps scrolling. Only visible rows are rendered in the DOM.
<script setup lang="ts">
import { ref } from 'vue'
const hugeData = Array.from({ length: 5000 }, (_, i) => ({
key: i + 1,
label: `Virtual item ${i + 1}`,
}))
const value = ref([100, 200, 500])
</script>
<template>
<VxTransfer
v-model="value"
:data="hugeData"
virtual-scroll
:item-height="36"
filterable
:titles="['5,000 Items', 'Selected']"
/>
</template>Remote Data (fetchOptions)
Load source data from an API with infinite scroll pagination. Pass an async fetchOptions(query, page) function that returns TransferItem[]. Scroll to the bottom to load the next page. Search input is debounced and triggers a fresh fetch.
<script setup lang="ts">
import { ref } from 'vue'
async function fetchComments(query: string, page: number) {
const res = await fetch(
`https://jsonplaceholder.typicode.com/comments?_limit=10&_page=${page}`
)
const data = await res.json()
return data.map((c: any) => ({ key: c.id, label: c.name }))
}
const value = ref([])
</script>
<template>
<VxTransfer
v-model="value"
:fetch-options="fetchComments"
filterable
:titles="['Comments (remote)', 'Selected']"
/>
</template>Loading
External loading state — shows a spinner at the bottom of the source panel.
<script setup lang="ts">
import { ref } from 'vue'
const data = Array.from({ length: 4 }, (_, i) => ({
key: i + 1,
label: `Item ${i + 1}`,
}))
const value = ref([1])
const loading = ref(true)
</script>
<template>
<VxTransfer v-model="value" :data="data" :loading="loading" />
</template>Target Order
By default items keep their original data order. Use target-order="push" to append items in the order they were selected.
<script setup lang="ts">
import { ref } from 'vue'
const data = Array.from({ length: 12 }, (_, i) => ({
key: i + 1,
label: `Item ${i + 1}`,
}))
const value = ref([2])
</script>
<template>
<VxTransfer v-model="value" :data="data" target-order="push" />
</template>Button Visibility
Hide one or both move buttons.
<script setup lang="ts">
import { ref } from 'vue'
const data = Array.from({ length: 6 }, (_, i) => ({
key: i + 1,
label: `Item ${i + 1}`,
}))
const value = ref([1, 3])
</script>
<template>
<!-- Only allow adding to target (no remove button) -->
<VxTransfer v-model="value" :data="data" :show-to-source-button="false" />
<!-- Only allow removing from target (no add button) -->
<VxTransfer v-model="value" :data="data" :show-to-target-button="false" />
</template>Custom Empty State
Use source-empty and target-empty slots to customize what's shown when a panel has no items.
<template>
<VxTransfer :data="[]">
<template #source-empty>
<div style="text-align: center; padding: 20px;">
<div style="font-size: 24px;">📭</div>
<div>Nothing here yet</div>
<div style="opacity: 0.7;">Add items to get started</div>
</div>
</template>
<template #target-empty>
<div style="text-align: center; padding: 20px;">
<div style="font-size: 24px;">🎯</div>
<div>No selections</div>
<div style="opacity: 0.7;">Move items from the left</div>
</div>
</template>
</VxTransfer>
</template>Disabled
<script setup lang="ts">
import { ref } from 'vue'
const data = Array.from({ length: 6 }, (_, i) => ({
key: i + 1,
label: `Item ${i + 1}`,
}))
const value = ref([])
</script>
<template>
<VxTransfer v-model="value" :data="data" disabled />
</template>Props
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | (string | number)[] | [] | Keys in target list (v-model) |
data | TransferItem[] | [] | Full data source |
titles | [string, string] | ['Source', 'Target'] | Panel titles |
filterable | boolean | false | Show search input |
filterPlaceholder | string | 'Search...' | Search placeholder |
filterMethod | (query, item) => boolean | — | Custom filter function |
disabled | boolean | false | Disable all interaction |
loading | boolean | false | External loading state |
targetOrder | 'original' | 'push' | 'original' | Order of target items |
buttonTexts | [string?, string?] | [] | Labels for move buttons |
showToTargetButton | boolean | true | Show move-to-target button |
showToSourceButton | boolean | true | Show move-to-source button |
showMoveAll | boolean | false | Show move-all buttons |
pageSize | number | 0 | Items per page (0 = show all) |
scrollDistance | number | 30 | Pixels from bottom to trigger load |
fetchOptions | (query, page) => Promise<TransferItem[]> | — | Async remote source |
debounce | number | 300 | Debounce for remote search (ms) |
keyField | string | 'key' | Property name for item key |
labelField | string | 'label' | Property name for item label |
panelWidth | number | string | 240 | Panel width (px or CSS value) |
sortMethod | (a, b) => number | — | Sort comparator for items |
draggable | boolean | false | Enable drag and drop between panels |
virtualScroll | boolean | false | Enable virtual scrolling for large lists |
itemHeight | number | 36 | Row height in px (for virtual scroll) |
Events
| Event | Type | Description |
|---|---|---|
update:modelValue | (value) | Target keys changed |
change | (value, direction, movedKeys) | Items moved |
load-more | (page) | Next remote page requested |
Slots
| Slot | Props | Description |
|---|---|---|
item | { item } | Custom item rendering |
source-header | { checked, total, query } | Custom source panel header |
target-header | { checked, total, query } | Custom target panel header |
source-footer | { checked, total } | Custom source panel footer |
target-footer | { checked, total } | Custom target panel footer |
source-empty | — | Source empty state |
target-empty | — | Target empty state |
to-target-button | { move, disabled, checkedCount } | Custom move-to-target button |
to-source-button | { move, disabled, checkedCount } | Custom move-to-source button |
Exposed Methods
| Method | Description |
|---|---|
moveRight() | Move checked items to target |
moveLeft() | Move checked items to source |
moveAllRight() | Move all enabled items to target |
moveAllLeft() | Move all enabled items to source |
TransferItem Interface
interface TransferItem {
key: string | number
label: string
disabled?: boolean
[k: string]: any
}