# Transfer List

> Dual listbox with search, checkbox selection and move selected/all buttons between Available and Chosen.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/transfer-list
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/transfer-list.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/transfer-list-demo.tsx"
"use client"

import * as React from "react"

import { TransferList, type TransferListItem } from "@/components/transfer-list"

const permissions: TransferListItem[] = [
  { id: "read", label: "View projects", description: "See projects and tasks" },
  { id: "comment", label: "Comment", description: "Reply in threads" },
  { id: "edit", label: "Edit content", description: "Change docs and tasks" },
  { id: "invite", label: "Invite members", description: "Send workspace invites" },
  { id: "billing", label: "Manage billing", description: "Plans and invoices" },
  { id: "export", label: "Export data", description: "Download CSV and PDF" },
  { id: "api", label: "API access", description: "Create personal tokens" },
  { id: "delete", label: "Delete workspace", description: "Owner only", disabled: true },
]

export default function TransferListDemo() {
  const [chosen, setChosen] = React.useState<string[]>(["read", "comment"])

  return (
    <div className="grid w-full max-w-2xl grid-cols-[minmax(0,1fr)] gap-3">
      <div>
        <h3 className="text-sm font-semibold">Role permissions</h3>
        <p className="text-xs text-muted-foreground">
          Select with click or Space, move with the arrows or Enter. {chosen.length} permission{chosen.length === 1 ? "" : "s"} granted.
        </p>
      </div>
      <TransferList items={permissions} chosen={chosen} onChosenChange={setChosen} />
    </div>
  )
}
```

## Source

```tsx title="components/transfer-list.tsx"
"use client"

import * as React from "react"
import { Check, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Minus, Search } from "lucide-react"

import { cn } from "@/lib/utils"

export type TransferListItem = {
  id: string
  label: string
  description?: string
  disabled?: boolean
}

function filterItems(items: TransferListItem[], query: string) {
  const q = query.trim().toLowerCase()
  return q ? items.filter((i) => i.label.toLowerCase().includes(q) || i.description?.toLowerCase().includes(q)) : items
}

interface PanelProps {
  title: string
  items: TransferListItem[]
  query: string
  onQueryChange: (query: string) => void
  selected: string[]
  onSelectedChange: (ids: string[]) => void
  /** Called for Enter / double-click */
  onMove: (ids: string[]) => void
}

function TransferPanel({ title, items, query, onQueryChange, selected, onSelectedChange, onMove }: PanelProps) {
  const baseId = React.useId()
  const [active, setActive] = React.useState(0)
  const [focused, setFocused] = React.useState(false)

  const visible = filterItems(items, query)
  const selectable = visible.filter((i) => !i.disabled)
  const activeIndex = Math.min(active, Math.max(visible.length - 1, 0))
  const activeItem = visible[activeIndex]
  const selectedVisible = selectable.filter((i) => selected.includes(i.id)).length
  const allSelected = selectable.length > 0 && selectedVisible === selectable.length
  const someSelected = selectedVisible > 0 && !allSelected
  const optionId = (id: string) => `${baseId}-opt-${id}`

  const activeId = activeItem?.id
  React.useEffect(() => {
    if (!focused || !activeId) return
    document.getElementById(`${baseId}-opt-${activeId}`)?.scrollIntoView({ block: "nearest" })
  }, [focused, activeId, baseId])

  function toggle(id: string) {
    onSelectedChange(selected.includes(id) ? selected.filter((s) => s !== id) : [...selected, id])
  }

  function toggleAll() {
    const ids = selectable.map((i) => i.id)
    onSelectedChange(allSelected ? selected.filter((s) => !ids.includes(s)) : Array.from(new Set([...selected, ...ids])))
  }

  function onKeyDown(event: React.KeyboardEvent<HTMLUListElement>) {
    switch (event.key) {
      case "ArrowDown":
        event.preventDefault()
        setActive(Math.min(activeIndex + 1, visible.length - 1))
        break
      case "ArrowUp":
        event.preventDefault()
        setActive(Math.max(activeIndex - 1, 0))
        break
      case "Home":
        event.preventDefault()
        setActive(0)
        break
      case "End":
        event.preventDefault()
        setActive(Math.max(visible.length - 1, 0))
        break
      case " ":
        event.preventDefault()
        if (activeItem && !activeItem.disabled) toggle(activeItem.id)
        break
      case "Enter": {
        event.preventDefault()
        const ids = selected.length ? selected : activeItem && !activeItem.disabled ? [activeItem.id] : []
        if (ids.length) onMove(ids)
        break
      }
      case "a":
      case "A":
        if (event.ctrlKey || event.metaKey) {
          event.preventDefault()
          toggleAll()
        }
        break
    }
  }

  return (
    <section aria-label={title} className="flex min-w-0 flex-col overflow-hidden rounded-xl border bg-card">
      <header className="flex items-center gap-2.5 border-b px-3 py-2.5">
        <button
          type="button"
          role="checkbox"
          aria-checked={someSelected ? "mixed" : allSelected}
          aria-label={`Select all ${title.toLowerCase()} items`}
          disabled={selectable.length === 0}
          onClick={toggleAll}
          className={cn(
            "flex size-4 shrink-0 items-center justify-center rounded border transition-colors focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40 disabled:opacity-40",
            allSelected || someSelected ? "border-primary bg-primary text-primary-foreground" : "bg-background"
          )}
        >
          {allSelected && <Check className="size-3" aria-hidden />}
          {someSelected && <Minus className="size-3" aria-hidden />}
        </button>
        <h3 className="min-w-0 flex-1 truncate text-sm font-semibold">{title}</h3>
        <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
          {selected.length}/{items.length} selected
        </span>
      </header>
      <div className="relative border-b p-2">
        <Search className="pointer-events-none absolute top-1/2 left-4.5 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
        <input
          type="search"
          aria-label={`Search ${title.toLowerCase()}`}
          placeholder="Search…"
          autoComplete="off"
          value={query}
          onChange={(event) => {
            onQueryChange(event.target.value)
            setActive(0)
          }}
          className="h-8 w-full rounded-lg border bg-background pr-2 pl-8 text-sm outline-none transition-[box-shadow,border-color] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/40"
        />
      </div>
      <ul
        role="listbox"
        tabIndex={0}
        aria-multiselectable
        aria-label={`${title} items`}
        aria-activedescendant={activeItem ? optionId(activeItem.id) : undefined}
        onKeyDown={onKeyDown}
        onFocus={() => setFocused(true)}
        onBlur={() => setFocused(false)}
        className="h-52 overflow-y-auto p-1 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-inset focus-visible:ring-ring/40"
      >
        {visible.map((item, index) => {
          const isSelected = selected.includes(item.id)
          return (
            <li
              key={item.id}
              id={optionId(item.id)}
              role="option"
              aria-selected={isSelected}
              aria-disabled={item.disabled || undefined}
              onClick={() => {
                setActive(index)
                if (!item.disabled) toggle(item.id)
              }}
              onDoubleClick={() => !item.disabled && onMove([item.id])}
              className={cn(
                "flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 text-sm select-none",
                isSelected && "bg-accent text-accent-foreground",
                focused && index === activeIndex && "ring-2 ring-inset ring-ring/60",
                !isSelected && "hover:bg-muted/60",
                item.disabled && "cursor-not-allowed opacity-50"
              )}
            >
              <span
                aria-hidden
                className={cn(
                  "flex size-4 shrink-0 items-center justify-center rounded border",
                  isSelected ? "border-primary bg-primary text-primary-foreground" : "bg-background"
                )}
              >
                {isSelected && <Check className="size-3" />}
              </span>
              <span className="min-w-0 flex-1">
                <span className="block truncate">{item.label}</span>
                {item.description && <span className="block truncate text-xs text-muted-foreground">{item.description}</span>}
              </span>
            </li>
          )
        })}
        {visible.length === 0 && (
          <li role="presentation" className="px-2 py-8 text-center text-sm text-muted-foreground">
            {items.length === 0 ? "Nothing here yet." : "No matches."}
          </li>
        )}
      </ul>
    </section>
  )
}

export interface TransferListProps
  extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
  items: TransferListItem[]
  /** Controlled ids in the "Chosen" list (order preserved) */
  chosen?: string[]
  defaultChosen?: string[]
  onChosenChange?: (ids: string[]) => void
  titles?: [string, string]
}

export function TransferList({
  items,
  chosen,
  defaultChosen = [],
  onChosenChange,
  titles = ["Available", "Chosen"],
  className,
  ...props
}: TransferListProps) {
  const [inner, setInner] = React.useState(defaultChosen)
  const chosenIds = chosen ?? inner
  const [selA, setSelA] = React.useState<string[]>([])
  const [selB, setSelB] = React.useState<string[]>([])
  const [queryA, setQueryA] = React.useState("")
  const [queryB, setQueryB] = React.useState("")
  const [announcement, setAnnouncement] = React.useState("")

  const available = items.filter((i) => !chosenIds.includes(i.id))
  const chosenItems = chosenIds
    .map((id) => items.find((i) => i.id === id))
    .filter((i): i is TransferListItem => Boolean(i))

  function commit(next: string[]) {
    if (chosen === undefined) setInner(next)
    onChosenChange?.(next)
  }

  function moveRight(ids: string[]) {
    const ordered = items.filter((i) => ids.includes(i.id)).map((i) => i.id)
    if (!ordered.length) return
    commit([...chosenIds, ...ordered])
    setSelA((s) => s.filter((id) => !ordered.includes(id)))
    setAnnouncement(`Moved ${ordered.length} item${ordered.length > 1 ? "s" : ""} to ${titles[1]}`)
  }

  function moveLeft(ids: string[]) {
    const moved = chosenIds.filter((id) => ids.includes(id))
    if (!moved.length) return
    commit(chosenIds.filter((id) => !ids.includes(id)))
    setSelB((s) => s.filter((id) => !moved.includes(id)))
    setAnnouncement(`Moved ${moved.length} item${moved.length > 1 ? "s" : ""} to ${titles[0]}`)
  }

  const allRight = filterItems(available, queryA).filter((i) => !i.disabled).map((i) => i.id)
  const allLeft = filterItems(chosenItems, queryB).filter((i) => !i.disabled).map((i) => i.id)
  const selAvail = selA.filter((id) => available.some((i) => i.id === id))
  const selChosen = selB.filter((id) => chosenIds.includes(id))

  const moveBtn =
    "inline-flex size-9 items-center justify-center rounded-lg border bg-background transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-40"

  return (
    <div className={cn("w-full", className)} {...props}>
      <div className="grid grid-cols-[minmax(0,1fr)] items-center gap-3 md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]">
        <TransferPanel
          title={titles[0]}
          items={available}
          query={queryA}
          onQueryChange={setQueryA}
          selected={selAvail}
          onSelectedChange={setSelA}
          onMove={moveRight}
        />
        <div role="group" aria-label="Move items" className="flex justify-center gap-2 md:flex-col">
          <button type="button" aria-label={`Move all to ${titles[1]}`} disabled={allRight.length === 0} onClick={() => moveRight(allRight)} className={moveBtn}>
            <ChevronsRight className="size-4 max-md:rotate-90" aria-hidden />
          </button>
          <button type="button" aria-label={`Move selected to ${titles[1]}`} disabled={selAvail.length === 0} onClick={() => moveRight(selAvail)} className={moveBtn}>
            <ChevronRight className="size-4 max-md:rotate-90" aria-hidden />
          </button>
          <button type="button" aria-label={`Move selected to ${titles[0]}`} disabled={selChosen.length === 0} onClick={() => moveLeft(selChosen)} className={moveBtn}>
            <ChevronLeft className="size-4 max-md:rotate-90" aria-hidden />
          </button>
          <button type="button" aria-label={`Move all to ${titles[0]}`} disabled={allLeft.length === 0} onClick={() => moveLeft(allLeft)} className={moveBtn}>
            <ChevronsLeft className="size-4 max-md:rotate-90" aria-hidden />
          </button>
        </div>
        <TransferPanel
          title={titles[1]}
          items={chosenItems}
          query={queryB}
          onQueryChange={setQueryB}
          selected={selChosen}
          onSelectedChange={setSelB}
          onMove={moveLeft}
        />
      </div>
      <p role="status" aria-live="polite" className="sr-only">
        {announcement}
      </p>
    </div>
  )
}
```
