Transfer List

Move items between available and selected lists with search and multi-selection.

@drift-ui/transfer-listRegistry JSON
Available Permissions
0 / 4
  • Delete RecordsPermanently delete rows
  • Export CSVDownload data dumps
  • Manage BillingInvoices and subscription
  • AdministratorFull workspace configuration
Assigned Permissions
0 / 2
  • Read DataView dashboard and reports
  • Write DataCreate and edit records
On this page

Installation

The CLI also installs the npm packages and registry items it builds on. Register the namespace once first with https://drift-ui.dev/r/{style}/{name}.json. The CLI fills in {style} from your components.json and installs the Base UI or Radix build to match. See setup.

Usage

transfer-list-usage.tsx
import { TransferList } from "@/components/ui/transfer-list"

const roles = [
  { id: "read", label: "Read" },
  { id: "write", label: "Write" },
  { id: "admin", label: "Admin" },
]

export function PermissionsField() {
  return (
    <TransferList
      items={roles}
      defaultValue={["read"]}
      titles={["Available", "Assigned"]}
      searchable
    />
  )
}

API reference

TransferList

Dual-panel listbox for moving items between available and selected sets with search and multi-selection.

Prop
itemsRequiredT[]All items across both lists, each with id, label and optional description and disabled.
valuestring[]Controlled keys in the selected panel.
defaultValuestring[]= []Initial value when uncontrolled.
onValueChange(value: string[]) => voidCalled after items are moved between lists.
titles[string, string]= ["Available", "Selected"]Headers for the source and target panels.
searchableboolean= falseAdds a search input to each panel.
showSelectAllboolean= trueRenders select-all checkboxes in both panel headers.
disabledboolean= falseDisables interaction across both panels and action buttons.
renderItem(item: T) => React.ReactNodeCustom renderer for items inside the listboxes.

Other props go to the <div>.

Source

components/ui/transfer-list.tsx
"use client"

import * as React from "react"
import {
  CheckIcon,
  ChevronLeftIcon,
  ChevronRightIcon,
  ChevronsLeftIcon,
  ChevronsRightIcon,
  MinusIcon,
  SearchIcon,
  XIcon,
} from "lucide-react"

import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import {
  filterTransferItems,
  getNavigationTarget,
  getTransferAnnouncement,
  moveItems,
  partitionItems,
  selectRange,
  type TransferItem,
} from "@/lib/transfer-list"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

function SelectionMark({
  checked,
  indeterminate,
}: {
  checked: boolean
  indeterminate?: boolean
}) {
  return (
    <span
      data-checked={checked ? "" : undefined}
      className="flex size-4 shrink-0 items-center justify-center rounded-lg border border-input transition-colors data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground"
    >
      {checked && <CheckIcon className="size-3 stroke-3" />}
      {indeterminate && !checked && (
        <MinusIcon className="size-3 stroke-3 text-foreground" />
      )}
    </span>
  )
}

function useTransferSide<T extends TransferItem>(items: T[]) {
  const [checkedIds, setCheckedIds] = React.useState<string[]>([])
  const [query, setQuery] = React.useState("")

  const visibleItems = filterTransferItems(items, query)
  const enabledIds = visibleItems
    .filter((item) => !item.disabled)
    .map((item) => item.id)
  const movableIds = enabledIds.filter((id) => checkedIds.includes(id))

  return {
    checkedIds,
    setCheckedIds,
    query,
    setQuery,
    visibleItems,
    enabledIds,
    movableIds,
  }
}

interface TransferPanelProps<T extends TransferItem> {
  title: string
  side: ReturnType<typeof useTransferSide<T>>
  searchable: boolean
  showSelectAll: boolean
  disabled: boolean
  renderItem?: (item: T) => React.ReactNode
  onItemMove: (id: string) => void
}

function TransferPanel<T extends TransferItem>({
  title,
  side,
  searchable,
  showSelectAll,
  disabled,
  renderItem,
  onItemMove,
}: TransferPanelProps<T>) {
  const {
    checkedIds,
    setCheckedIds,
    query,
    setQuery,
    visibleItems,
    enabledIds,
    movableIds,
  } = side
  const [anchorId, setAnchorId] = React.useState<string | null>(null)
  const [focusedId, setFocusedId] = React.useState<string | null>(null)
  const listRef = React.useRef<HTMLUListElement>(null)

  const tabStopId =
    focusedId && enabledIds.includes(focusedId) ? focusedId : enabledIds[0]
  const isAllChecked =
    enabledIds.length > 0 && movableIds.length === enabledIds.length
  const isSomeChecked = movableIds.length > 0 && !isAllChecked

  function handleToggleAll() {
    setCheckedIds((current) =>
      isAllChecked
        ? current.filter((id) => !enabledIds.includes(id))
        : Array.from(new Set([...current, ...enabledIds]))
    )
  }

  function toggleItem(id: string, extendRange: boolean) {
    if (extendRange && anchorId) {
      setCheckedIds(selectRange(enabledIds, anchorId, id, checkedIds))
    } else {
      setCheckedIds((current) =>
        current.includes(id)
          ? current.filter((checkedId) => checkedId !== id)
          : [...current, id]
      )
    }
    setAnchorId(id)
  }

  function handleItemKeyDown(event: React.KeyboardEvent, id: string) {
    if (event.key === " " || event.key === "Enter") {
      event.preventDefault()
      toggleItem(id, event.shiftKey)
      return
    }

    const nextId = getNavigationTarget(enabledIds, id, event.key)
    if (!nextId) return

    event.preventDefault()
    if (event.shiftKey) {
      setCheckedIds(selectRange(enabledIds, id, nextId, checkedIds))
      setAnchorId(nextId)
    }
    listRef.current
      ?.querySelectorAll<HTMLElement>(
        '[role="option"]:not([aria-disabled="true"])'
      )
      [enabledIds.indexOf(nextId)]?.focus()
  }

  return (
    <div
      className={cn(
        "flex w-full min-w-0 flex-1 flex-col rounded-lg border border-border bg-card text-card-foreground shadow-xs transition-opacity",
        disabled && "opacity-60"
      )}
    >
      <div className="flex items-center justify-between border-b border-border px-3 py-2.5">
        <div className="flex min-w-0 items-center gap-2">
          {showSelectAll && (
            <button
              type="button"
              role="checkbox"
              aria-checked={isSomeChecked ? "mixed" : isAllChecked}
              aria-label={`Select all ${title}`}
              disabled={disabled || enabledIds.length === 0}
              onClick={handleToggleAll}
              className="flex items-center justify-center rounded p-0.5 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
            >
              <SelectionMark
                checked={isAllChecked}
                indeterminate={isSomeChecked}
              />
            </button>
          )}
          <span className="truncate text-sm font-medium">{title}</span>
        </div>
        <span className="shrink-0 text-xs text-muted-foreground">
          {movableIds.length} / {visibleItems.length}
        </span>
      </div>

      {searchable && (
        <div className="relative border-b border-border p-2">
          <SearchIcon className="absolute top-1/2 left-4 size-3.5 -translate-y-1/2 text-muted-foreground" />
          <Input
            aria-label={`Search ${title}`}
            value={query}
            onChange={(event) => setQuery(event.target.value)}
            onKeyDown={(event) => {
              if (event.key === "Enter") event.preventDefault()
            }}
            placeholder={`Search ${title.toLowerCase()}...`}
            disabled={disabled}
            className="h-8 pr-7 pl-8 text-xs md:text-xs"
          />
          {query && (
            <button
              type="button"
              aria-label={`Clear search ${title}`}
              disabled={disabled}
              onClick={() => setQuery("")}
              className="absolute top-1/2 right-3.5 -translate-y-1/2 rounded text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
            >
              <XIcon className="size-3" />
            </button>
          )}
        </div>
      )}

      <ul
        ref={listRef}
        role="listbox"
        aria-multiselectable="true"
        aria-label={title}
        className="h-64 overflow-y-auto p-1.5"
      >
        {visibleItems.length === 0 ? (
          <li
            role="presentation"
            className="flex h-full items-center justify-center p-4 text-center text-xs text-muted-foreground"
          >
            No items
          </li>
        ) : (
          visibleItems.map((item) => {
            const isChecked = checkedIds.includes(item.id)
            const isInactive = disabled || item.disabled
            return (
              <li
                key={item.id}
                role="option"
                aria-selected={isChecked}
                aria-disabled={isInactive || undefined}
                tabIndex={
                  isInactive ? undefined : item.id === tabStopId ? 0 : -1
                }
                onFocus={() => setFocusedId(item.id)}
                onClick={(event) => {
                  if (!isInactive) toggleItem(item.id, event.shiftKey)
                }}
                onDoubleClick={() => {
                  if (!isInactive) onItemMove(item.id)
                }}
                onKeyDown={(event) => {
                  if (!isInactive) handleItemKeyDown(event, item.id)
                }}
                className={cn(
                  "flex cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs transition-colors select-none",
                  isChecked
                    ? "bg-accent text-accent-foreground"
                    : "hover:bg-muted/60",
                  item.disabled &&
                    "cursor-not-allowed opacity-40 hover:bg-transparent",
                  "focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
                )}
              >
                <SelectionMark checked={isChecked} />
                <div className="flex min-w-0 flex-1 flex-col">
                  {renderItem ? (
                    renderItem(item)
                  ) : (
                    <>
                      <span className="truncate font-medium">{item.label}</span>
                      {item.description && (
                        <span className="truncate text-[10px] text-muted-foreground">
                          {item.description}
                        </span>
                      )}
                    </>
                  )}
                </div>
              </li>
            )
          })
        )}
      </ul>
    </div>
  )
}

export interface TransferListProps<
  T extends TransferItem = TransferItem,
> extends Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> {
  items: T[]
  value?: string[]
  defaultValue?: string[]
  onValueChange?: (value: string[]) => void
  titles?: [string, string]
  searchable?: boolean
  showSelectAll?: boolean
  disabled?: boolean
  renderItem?: (item: T) => React.ReactNode
}

export function TransferList<T extends TransferItem = TransferItem>({
  items,
  value,
  defaultValue = [],
  onValueChange,
  titles = ["Available", "Selected"],
  searchable = false,
  showSelectAll = true,
  disabled = false,
  renderItem,
  className,
  ...props
}: TransferListProps<T>) {
  const { value: selectedIds, setValue: setSelectedIds } = useControllableState(
    {
      value,
      defaultValue,
      onChange: onValueChange,
    }
  )
  const [announcement, setAnnouncement] = React.useState("")

  const { sourceItems, targetItems } = partitionItems(items, selectedIds)
  const source = useTransferSide(sourceItems)
  const target = useTransferSide(targetItems)

  function move(ids: string[], direction: "toTarget" | "toSource") {
    const origin = direction === "toTarget" ? source : target
    const destination = direction === "toTarget" ? titles[1] : titles[0]
    const labels = items
      .filter((item) => ids.includes(item.id))
      .map((item) => item.label)

    setSelectedIds(moveItems(selectedIds, ids, direction))
    origin.setCheckedIds((current) => current.filter((id) => !ids.includes(id)))
    setAnnouncement(getTransferAnnouncement(labels, destination))
  }

  const panelProps = { searchable, showSelectAll, disabled, renderItem }

  return (
    <div
      role="group"
      aria-label="Transfer list"
      className={cn("flex flex-col items-center gap-3 sm:flex-row", className)}
      {...props}
    >
      <TransferPanel
        title={titles[0]}
        side={source}
        onItemMove={(id) => move([id], "toTarget")}
        {...panelProps}
      />

      <div className="flex flex-row gap-1 sm:flex-col">
        <Button
          type="button"
          variant="outline"
          size="icon"
          aria-label={`Move all to ${titles[1]}`}
          disabled={disabled || source.enabledIds.length === 0}
          onClick={() => move(source.enabledIds, "toTarget")}
          className="size-8"
        >
          <ChevronsRightIcon className="size-4 rotate-90 sm:rotate-0" />
        </Button>
        <Button
          type="button"
          variant="outline"
          size="icon"
          aria-label={`Move selected to ${titles[1]}`}
          disabled={disabled || source.movableIds.length === 0}
          onClick={() => move(source.movableIds, "toTarget")}
          className="size-8"
        >
          <ChevronRightIcon className="size-4 rotate-90 sm:rotate-0" />
        </Button>
        <Button
          type="button"
          variant="outline"
          size="icon"
          aria-label={`Move selected to ${titles[0]}`}
          disabled={disabled || target.movableIds.length === 0}
          onClick={() => move(target.movableIds, "toSource")}
          className="size-8"
        >
          <ChevronLeftIcon className="size-4 rotate-90 sm:rotate-0" />
        </Button>
        <Button
          type="button"
          variant="outline"
          size="icon"
          aria-label={`Move all to ${titles[0]}`}
          disabled={disabled || target.enabledIds.length === 0}
          onClick={() => move(target.enabledIds, "toSource")}
          className="size-8"
        >
          <ChevronsLeftIcon className="size-4 rotate-90 sm:rotate-0" />
        </Button>
      </div>

      <TransferPanel
        title={titles[1]}
        side={target}
        onItemMove={(id) => move([id], "toSource")}
        {...panelProps}
      />

      <span role="status" aria-live="polite" className="sr-only">
        {announcement}
      </span>
    </div>
  )
}