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.
npx shadcn@latest add @paragon/transfer-listAlso installs: tooltip
"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>
);
}