Transfer List
Two lists side by side: pick items on one side and move them across; moved items slide between columns.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/transfer-list.json'use client'
import { useState } from 'react'
import './transfer-list.css'
export interface TransferItem { id: string; label: string }
export interface TransferListProps {
items: TransferItem[]
defaultValue?: string[]
onValueChange?: (ids: string[]) => void
filter?: boolean
titles?: [string, string]
fill?: 'dark' | 'light'
className?: string
}
/** Move items between Available and Selected lists. */
export function TransferList({ items, defaultValue = [], onValueChange, filter = true, titles = ['Available', 'Selected'], fill = 'dark', className }: TransferListProps) {
const [right, setRight] = useState(defaultValue)
const [checked, setChecked] = useState<string[]>([])
const [q, setQ] = useState(['', ''])
const [moved, setMoved] = useState<{ ids: string[]; dir: 1 | -1 }>({ ids: [], dir: 1 })
const [msg, setMsg] = useState('')
const sides = [items.filter((i) => !right.includes(i.id)), items.filter((i) => right.includes(i.id))]
const vis = (s: 0 | 1) => sides[s].filter((i) => i.label.toLowerCase().includes(q[s].toLowerCase()))
const sel = (s: 0 | 1) => vis(s).filter((i) => checked.includes(i.id)).map((i) => i.id)
const move = (ids: string[], dir: 1 | -1) => {
const n = dir > 0 ? [...right, ...ids] : right.filter((x) => !ids.includes(x))
setRight(n); setChecked((c) => c.filter((x) => !ids.includes(x))); setMoved({ ids, dir }); onValueChange?.(n)
setMsg(`Moved ${ids.length} to ${dir > 0 ? titles[1] : titles[0]}`)
}
const col = (s: 0 | 1) => (
<div className="sd-trf__col" role="group" aria-label={titles[s]}>
<div className="sd-trf__h">{titles[s]}<span>{sel(s).length ? `${sel(s).length}/` : ''}{sides[s].length}</span></div>
{filter && <input className="sd-trf__q" aria-label={`Filter ${titles[s]}`} placeholder="Filter" value={q[s]} onChange={(e) => setQ(s ? [q[0], e.target.value] : [e.target.value, q[1]])} />}
<div className="sd-trf__list">
{vis(s).map((it) => (
<label key={it.id} className="sd-trf__it" style={moved.ids.includes(it.id) ? ({ '--from': `${moved.dir * -40}px` } as React.CSSProperties) : { animation: 'none' }}>
<input type="checkbox" checked={checked.includes(it.id)} onChange={() => setChecked((c) => (c.includes(it.id) ? c.filter((x) => x !== it.id) : [...c, it.id]))} />{it.label}
</label>
))}
</div>
</div>
)
const arrow = (d: string) => <svg viewBox="0 0 14 14" aria-hidden><path d={d} /></svg>
return (
<div className={['sd-trf', className].filter(Boolean).join(' ')} data-fill={fill}>
{col(0)}
<div className="sd-trf__mid">
<button type="button" className="sd-trf__mv" aria-label={`Move ${sel(0).length} selected to ${titles[1]}`} disabled={!sel(0).length} onClick={() => move(sel(0), 1)}>{arrow('M5 3l4 4-4 4')}</button>
<button type="button" className="sd-trf__mv" aria-label={`Move all to ${titles[1]}`} disabled={!sides[0].length} onClick={() => move(sides[0].map((i) => i.id), 1)}>{arrow('M3 3l4 4-4 4M7 3l4 4-4 4')}</button>
<button type="button" className="sd-trf__mv" aria-label={`Move ${sel(1).length} selected to ${titles[0]}`} disabled={!sel(1).length} onClick={() => move(sel(1), -1)}>{arrow('M9 3 5 7l4 4')}</button>
</div>
{col(1)}
<span className="sr" aria-live="polite">{msg}</span>
</div>
)
}.sd-trf { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-trf[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-trf button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-trf :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-trf .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-trf { width: min(100%, 360px); display: grid; grid-template-columns: 1fr auto 1fr; gap: 8px; align-items: stretch; }
@media (max-width: 420px) { .sd-trf { grid-template-columns: 1fr; } .sd-trf__mid { flex-direction: row !important; justify-content: center; } .sd-trf__mid svg { transform: rotate(90deg); } }
.sd-trf__col { display: flex; flex-direction: column; min-width: 0; height: 280px; border-radius: 16px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; }
@media (max-width: 420px) { .sd-trf__col { height: 170px; } }
.sd-trf__h { display: flex; justify-content: space-between; padding: 10px 12px 6px; font-size: 12px; font-weight: 600; }
.sd-trf__h span { color: var(--mute); font-weight: 400; font-variant-numeric: tabular-nums; }
.sd-trf__q { margin: 0 8px 6px; height: 30px; padding: 0 9px; border: 0; border-radius: 8px; outline: 0; background: var(--soft); color: var(--fg); font: inherit; font-size: 12.5px; }
.sd-trf__list { flex: 1; overflow: auto; padding: 0 4px 6px; }
.sd-trf__it { display: flex; align-items: center; gap: 8px; min-height: 32px; padding: 0 8px; border-radius: 8px; font-size: 13px; cursor: pointer; animation: sd-trf-in 260ms cubic-bezier(0.2, 1, 0.3, 1); }
.sd-trf__it:hover { background: var(--soft); }
@keyframes sd-trf-in { from { opacity: 0; transform: translateX(var(--from, 0)); } }
.sd-trf__it input { margin: 0; accent-color: currentColor; color: var(--fg); }
.sd-trf__mid { display: flex; flex-direction: column; justify-content: center; gap: 6px; }
.sd-trf__mv { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 10px; box-shadow: inset 0 0 0 1px var(--line); background: var(--bg) !important; }
.sd-trf__mv:hover:not(:disabled) { box-shadow: inset 0 0 0 1px var(--fg); }
.sd-trf__mv:disabled { opacity: 0.3; cursor: default; }
.sd-trf__mv svg { width: 13px; height: 13px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
@media (prefers-reduced-motion: reduce) { .sd-trf *, .sd-trf *::before, .sd-trf *::after { animation: none !important; transition: none !important; } }Overview
Available and Selected lists each have checkboxes and a filter. The arrow buttons move the checked items across, and Move all moves everything. Items animate into their new list.
On narrow screens the columns stack and the arrows point down and up.
When to use
- Choosing columns to export, granting scopes, building a shortlist.
When not to use
- Small sets: a checkbox list is simpler.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/transfer-list.json$
npx shadcn@latest add https://sundaeui.dev/r/transfer-list.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/transfer-list.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/transfer-list.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy transfer-list.tsx and transfer-list.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { TransferList } from '@/components/sundae/transfer-list'
<TransferList />Props
| Prop | Type | Default |
|---|---|---|
items*All items. | { id: string; label: string }[] | — |
value / defaultValueIds on the right. | string[] | — |
onValueChangeCalled on change. | (ids: string[]) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Each list is a labelled group of checkboxes; move buttons name their direction and count.
- Moves are announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-trf; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Transfer List free?
- Yes. Transfer List is free for personal and commercial projects, with full source on this page.
- How do I install Transfer List?
- Run npx shadcn@latest add https://sundaeui.dev/r/transfer-list.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Transfer List support keyboard and screen readers?
- Each list is a labelled group of checkboxes; move buttons name their direction and count. Moves are announced.
- Does Transfer List respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Radio Cards
Forms · RefinedCheckbox Tree
Forms · RefinedOTP Field
Forms · Refined- Pro
Drop Zone
Forms · Refined Combobox
Forms · Understated- Pro
Onboarding Flow
Forms · Refined