Transfer List
Data Display

Transfer List

A dual-listbox for curating a subset, with full multi-select keyboard semantics, per-panel filtering that scopes move-all, and rows that animate across panels while siblings close the gap.

Install

npx shadcn@latest add @paragon/transfer-list

Also installs: tooltip

transfer-list.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
  Check,
  ChevronLeft,
  ChevronRight,
  ChevronsLeft,
  ChevronsRight,
  Inbox,
  Search,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";

export interface TransferItem {
  id: string;
  label: string;
  /** Secondary line under the label (role, email, …). */
  description?: string;
  disabled?: boolean;
}

export interface TransferListProps
  extends Omit<React.ComponentProps<"div">, "onChange"> {
  /** The full catalog. Items keep this order in both panels. */
  items: TransferItem[];
  /** Ids that start in the target (right) panel. */
  defaultTargetIds?: string[];
  onChange?: (targetIds: string[]) => void;
  sourceTitle?: string;
  targetTitle?: string;
  /** Per-panel filter inputs. */
  searchable?: boolean;
  /** Height of each list viewport in px. */
  listHeight?: number;
  emptySourceLabel?: string;
  emptyTargetLabel?: string;
  /** Disables the move/check motion. */
  static?: boolean;
}

type Side = "source" | "target";

/**
 * A dual-listbox for curating a subset — grant access, pick columns, build
 * a roster. Each panel is a real multi-select listbox (roving tabindex,
 * arrows, Space toggles, Enter moves the focused row across, ⌘/Ctrl+A
 * selects the panel). Rows crossing over exit toward their destination and
 * rise into the other panel while siblings close the gap with layout
 * animation. Filters scope both the list and the move-all buttons, and
 * every move is announced to screen readers.
 */
export function TransferList({
  items,
  defaultTargetIds = [],
  onChange,
  sourceTitle = "Available",
  targetTitle = "Selected",
  searchable = true,
  listHeight = 232,
  emptySourceLabel = "Nothing left to add",
  emptyTargetLabel = "Nothing selected yet",
  static: isStatic = false,
  className,
  ...props
}: TransferListProps) {
  const reducedMotion = useReducedMotion() ?? false;
  const noMotion = reducedMotion || isStatic;

  const [targetIds, setTargetIds] = React.useState<Set<string>>(
    () => new Set(defaultTargetIds),
  );
  const [checked, setChecked] = React.useState<Set<string>>(new Set());
  const [announce, setAnnounce] = React.useState("");

  const commit = (nextTarget: Set<string>) => {
    setTargetIds(nextTarget);
    onChange?.(items.filter((i) => nextTarget.has(i.id)).map((i) => i.id));
  };

  const move = (ids: string[], to: Side) => {
    if (ids.length === 0) return;
    const next = new Set(targetIds);
    for (const id of ids) {
      if (to === "target") next.add(id);
      else next.delete(id);
    }
    commit(next);
    setChecked((prev) => {
      const rest = new Set(prev);
      for (const id of ids) rest.delete(id);
      return rest;
    });
    const noun = ids.length === 1 ? "item" : "items";
    setAnnounce(
      to === "target"
        ? `${ids.length} ${noun} added to ${targetTitle}`
        : `${ids.length} ${noun} moved back to ${sourceTitle}`,
    );
  };

  const sourceItems = items.filter((i) => !targetIds.has(i.id));
  const targetItems = items.filter((i) => targetIds.has(i.id));
  const checkedIn = (list: TransferItem[]) =>
    list.filter((i) => checked.has(i.id)).map((i) => i.id);

  const checkedSource = checkedIn(sourceItems);
  const checkedTarget = checkedIn(targetItems);

  // Move-all respects each panel's current filter; panels report theirs up.
  const visibleRef = React.useRef<Record<Side, string[]>>({
    source: [],
    target: [],
  });

  const toggleCheck = (id: string) => {
    setChecked((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  };

  const panelProps = {
    searchable,
    listHeight,
    noMotion,
    checked,
    toggleCheck,
    onCheckMany: (ids: string[]) =>
      setChecked((prev) => new Set([...prev, ...ids])),
  };

  return (
    <TooltipProvider>
      <div
        data-slot="transfer-list"
        className={cn(
          "grid w-full grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3",
          className,
        )}
        {...props}
      >
        <Panel
          side="source"
          title={sourceTitle}
          items={sourceItems}
          emptyLabel={emptySourceLabel}
          onMoveItem={(id) => move([id], "target")}
          onVisibleChange={(ids) => (visibleRef.current.source = ids)}
          {...panelProps}
        />

        <div className="flex flex-col gap-1.5" role="group" aria-label="Move items">
          <MoveButton
            label={`Add selected (${checkedSource.length})`}
            disabled={checkedSource.length === 0}
            onClick={() => move(checkedSource, "target")}
            isStatic={isStatic}
          >
            <ChevronRight className="size-4" />
          </MoveButton>
          <MoveButton
            label={`Remove selected (${checkedTarget.length})`}
            disabled={checkedTarget.length === 0}
            onClick={() => move(checkedTarget, "source")}
            isStatic={isStatic}
          >
            <ChevronLeft className="size-4" />
          </MoveButton>
          <MoveButton
            label="Add all"
            disabled={sourceItems.length === 0}
            onClick={() =>
              move(
                visibleRef.current.source.filter(
                  (id) => !items.find((i) => i.id === id)?.disabled,
                ),
                "target",
              )
            }
            isStatic={isStatic}
          >
            <ChevronsRight className="size-4" />
          </MoveButton>
          <MoveButton
            label="Remove all"
            disabled={targetItems.length === 0}
            onClick={() =>
              move(
                visibleRef.current.target.filter(
                  (id) => !items.find((i) => i.id === id)?.disabled,
                ),
                "source",
              )
            }
            isStatic={isStatic}
          >
            <ChevronsLeft className="size-4" />
          </MoveButton>
        </div>

        <Panel
          side="target"
          title={targetTitle}
          items={targetItems}
          emptyLabel={emptyTargetLabel}
          onMoveItem={(id) => move([id], "source")}
          onVisibleChange={(ids) => (visibleRef.current.target = ids)}
          {...panelProps}
        />

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

function MoveButton({
  label,
  disabled,
  onClick,
  isStatic,
  children,
}: {
  label: string;
  disabled: boolean;
  onClick: () => void;
  isStatic: boolean;
  children: React.ReactNode;
}) {
  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <button
          type="button"
          aria-label={label}
          disabled={disabled}
          onClick={onClick}
          className={cn(
            "flex size-8 items-center justify-center rounded-lg bg-card text-muted-foreground shadow-border",
            "transition-[background-color,box-shadow,color,scale] duration-150 ease-out",
            "hover:not-disabled:text-foreground hover:not-disabled:shadow-border-hover",
            "disabled:cursor-not-allowed disabled:opacity-40",
            !isStatic && "active:not-disabled:scale-[0.97]",
          )}
        >
          {children}
        </button>
      </TooltipTrigger>
      <TooltipContent side="top">{label}</TooltipContent>
    </Tooltip>
  );
}

function Panel({
  side,
  title,
  items,
  emptyLabel,
  searchable,
  listHeight,
  noMotion,
  checked,
  toggleCheck,
  onCheckMany,
  onMoveItem,
  onVisibleChange,
}: {
  side: Side;
  title: string;
  items: TransferItem[];
  emptyLabel: string;
  searchable: boolean;
  listHeight: number;
  noMotion: boolean;
  checked: Set<string>;
  toggleCheck: (id: string) => void;
  onCheckMany: (ids: string[]) => void;
  onMoveItem: (id: string) => void;
  onVisibleChange: (ids: string[]) => void;
}) {
  const [query, setQuery] = React.useState("");
  const [focusedId, setFocusedId] = React.useState<string | null>(null);
  const rowRefs = React.useRef(new Map<string, HTMLLIElement>());
  const labelId = React.useId();

  const q = query.trim().toLowerCase();
  const visible = q
    ? items.filter(
        (i) =>
          i.label.toLowerCase().includes(q) ||
          i.description?.toLowerCase().includes(q),
      )
    : items;

  // Report the filtered view so "move all" only moves what the user can see.
  const visibleIds = visible.map((i) => i.id);
  const visibleKey = visibleIds.join("");
  const onVisibleChangeRef = React.useRef(onVisibleChange);
  onVisibleChangeRef.current = onVisibleChange;
  React.useEffect(() => {
    onVisibleChangeRef.current(visibleKey ? visibleKey.split("") : []);
  }, [visibleKey]);

  const focusRow = (id: string | undefined) => {
    if (!id) return;
    setFocusedId(id);
    rowRefs.current.get(id)?.focus();
  };

  const handleKeyDown = (event: React.KeyboardEvent, item: TransferItem) => {
    const ids = visible.map((i) => i.id);
    const index = ids.indexOf(item.id);
    switch (event.key) {
      case "ArrowDown":
        event.preventDefault();
        focusRow(ids[index + 1]);
        break;
      case "ArrowUp":
        event.preventDefault();
        focusRow(ids[index - 1]);
        break;
      case "Home":
        event.preventDefault();
        focusRow(ids[0]);
        break;
      case "End":
        event.preventDefault();
        focusRow(ids[ids.length - 1]);
        break;
      case " ":
        event.preventDefault();
        if (!item.disabled) toggleCheck(item.id);
        break;
      case "Enter":
        event.preventDefault();
        if (!item.disabled) {
          // Keep keyboard focus useful after the row leaves this panel.
          focusRow(ids[index + 1] ?? ids[index - 1]);
          onMoveItem(item.id);
        }
        break;
      case "a":
      case "A":
        if (event.metaKey || event.ctrlKey) {
          event.preventDefault();
          onCheckMany(visible.filter((i) => !i.disabled).map((i) => i.id));
        }
        break;
    }
  };

  const focusTarget = focusedId && visible.some((i) => i.id === focusedId)
    ? focusedId
    : visible[0]?.id;

  const checkedCount = visible.filter((i) => checked.has(i.id)).length;

  return (
    <section
      aria-labelledby={labelId}
      className="flex min-w-0 flex-col overflow-hidden rounded-xl bg-card shadow-border"
    >
      <header className="flex items-center justify-between gap-2 border-b px-3 py-2.5">
        <h3 id={labelId} className="min-w-0 truncate text-[13px] font-medium" title={title}>
          {title}
        </h3>
        <span className="shrink-0 text-[11px] text-muted-foreground tabular-nums">
          {checkedCount > 0 ? `${checkedCount} of ${items.length}` : items.length}
        </span>
      </header>

      {searchable && (
        <div className="relative border-b">
          <Search
            aria-hidden
            className="pointer-events-none absolute top-1/2 left-3 size-3.5 -translate-y-1/2 text-muted-foreground/70"
          />
          <input
            type="text"
            value={query}
            onChange={(event) => setQuery(event.target.value)}
            placeholder="Filter…"
            aria-label={`Filter ${title}`}
            className="h-9 w-full bg-transparent pr-3 pl-9 text-[13px] outline-none placeholder:text-muted-foreground/70 focus-visible:bg-muted/30"
          />
        </div>
      )}

      <ul
        role="listbox"
        aria-multiselectable="true"
        aria-labelledby={labelId}
        className="flex flex-col gap-0.5 overflow-y-auto p-1.5"
        style={{ height: listHeight }}
      >
        <AnimatePresence initial={false} mode="popLayout">
          {visible.map((item) => {
            const isChecked = checked.has(item.id);
            return (
              <motion.li
                key={item.id}
                role="option"
                aria-selected={isChecked}
                aria-disabled={item.disabled || undefined}
                tabIndex={focusTarget === item.id ? 0 : -1}
                ref={(el: HTMLLIElement | null) => {
                  if (el) rowRefs.current.set(item.id, el);
                  else rowRefs.current.delete(item.id);
                }}
                layout={noMotion ? false : "position"}
                initial={
                  noMotion
                    ? { opacity: 0 }
                    : { opacity: 0, y: 10, filter: "blur(4px)" }
                }
                animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
                exit={
                  noMotion
                    ? { opacity: 0, transition: { duration: 0.1 } }
                    : {
                        opacity: 0,
                        x: side === "source" ? 10 : -10,
                        filter: "blur(4px)",
                        transition: { duration: 0.15, ease: [0.4, 0, 1, 1] },
                      }
                }
                transition={{ type: "spring", duration: 0.35, bounce: 0 }}
                onClick={() => {
                  if (item.disabled) return;
                  setFocusedId(item.id);
                  toggleCheck(item.id);
                }}
                onDoubleClick={() => {
                  if (!item.disabled) onMoveItem(item.id);
                }}
                onKeyDown={(event) => handleKeyDown(event, item)}
                onFocus={() => setFocusedId(item.id)}
                className={cn(
                  "group flex cursor-default items-center gap-2.5 rounded-lg px-2 py-1.5 select-none",
                  "transition-colors duration-(--duration-fast)",
                  item.disabled
                    ? "opacity-45"
                    : isChecked
                      ? "bg-muted/60"
                      : "hover:bg-muted/40",
                )}
              >
                <span
                  aria-hidden
                  className={cn(
                    "flex size-4 shrink-0 items-center justify-center rounded-[4px] border",
                    "transition-[background-color,border-color] duration-(--duration-fast) ease-(--ease-out)",
                    isChecked
                      ? "border-primary bg-primary text-primary-foreground"
                      : "border-input bg-card group-hover:border-ring/60",
                  )}
                >
                  <Check
                    className={cn(
                      "size-3 transition-[opacity,scale] duration-(--duration-fast) ease-(--ease-out)",
                      isChecked ? "scale-100 opacity-100" : "scale-50 opacity-0",
                    )}
                    strokeWidth={2.5}
                  />
                </span>
                <span className="min-w-0 flex-1">
                  <span
                    className="block truncate text-[13px] text-foreground"
                    title={item.label}
                  >
                    {item.label}
                  </span>
                  {item.description && (
                    <span
                      className="block truncate text-[11px] text-muted-foreground"
                      title={item.description}
                    >
                      {item.description}
                    </span>
                  )}
                </span>
              </motion.li>
            );
          })}
        </AnimatePresence>

        {visible.length === 0 && (
          <li
            role="presentation"
            className="flex flex-1 flex-col items-center justify-center gap-1.5 px-4 text-center"
          >
            <Inbox aria-hidden className="size-4 text-muted-foreground/50" />
            <span className="text-xs text-muted-foreground">
              {q ? (
                <>
                  No matches for{" "}
                  <span className="font-medium text-foreground">“{query}”</span>
                </>
              ) : (
                emptyLabel
              )}
            </span>
          </li>
        )}
      </ul>
    </section>
  );
}