List Collector
Composable dual-panel transfer list for moving items between source and target panels with selection, filtering, drag-and-drop, and empty states.
ListCollector is a composition-first primitive: parts you assemble into dual-panel pickers, column selectors, and permission transfer lists. Panel membership, multi-select, filtering, and drag reordering live in ListCollectorRoot — search inputs, transfer buttons, and row markup are recipes you layer on top. Two ready-made recipes ship in the registry and are documented in Registry examples below.
Installation
Exported from @by/experience-system:
pnpm add @by/experience-systemIn this monorepo, depend on the workspace package (for example via workspace:* or your catalog) so imports resolve to packages/experience-system.
Composition
Use the following composition to build a dual-panel transfer list:
ListCollectorRoot
└── ListCollectorLayout
├── ListCollectorPanel (panel="source")
│ ├── ListCollectorHeader
│ ├── ListCollectorSeparator (optional)
│ ├── ListCollectorFilter (optional)
│ ├── ListCollectorSeparator (optional)
│ └── ListCollectorViewport
│ ├── ListCollectorEmpty (optional)
│ ├── ListCollectorNoResults (optional)
│ └── ListCollectorList
│ └── ListCollectorItem (one per row)
├── ListCollectorControls (optional)
│ ├── ListCollectorMoveSelected (optional)
│ └── ListCollectorMoveAll (optional)
└── ListCollectorPanel (panel="target")
└── …same panel structure as source…ListCollectorRoot owns item membership, selection, filtering, and drag state. Each ListCollectorPanel must set panel="source" or panel="target". ListCollectorSeparator is a thin rule inside a panel between header/filter and the list — not a divider between panels. For large datasets, window rows with useListCollectorPanelState and real ListCollectorItem primitives instead of ListCollectorList; see Foundation → Virtualization.
Usage
import {
ListCollectorControls,
ListCollectorHeader,
ListCollectorItem,
ListCollectorLayout,
ListCollectorList,
ListCollectorMoveSelected,
ListCollectorPanel,
ListCollectorRoot,
ListCollectorSeparator,
ListCollectorViewport,
} from '@by/experience-system';ListCollectorRoot and its parts are client components ('use client' in the package). Use them inside a Client Component when using the Next.js App Router.
const items = [
{ id: 'a', label: 'Apple' },
{ id: 'b', label: 'Banana' },
];
<ListCollectorRoot
items={items}
getItemKey={(item) => item.id}
getItemText={(item) => item.label}
defaultValue={[]}
onValueChange={(keys) => console.info(keys)}
>
<ListCollectorLayout>
<ListCollectorPanel panel="source">
<ListCollectorHeader>Available</ListCollectorHeader>
<ListCollectorSeparator />
<ListCollectorViewport>
<ListCollectorList>
{({ item, value }) => (
<ListCollectorItem value={value}>{item.label}</ListCollectorItem>
)}
</ListCollectorList>
</ListCollectorViewport>
</ListCollectorPanel>
<ListCollectorControls>
<ListCollectorMoveSelected from="source" to="target">
Add selected
</ListCollectorMoveSelected>
<ListCollectorMoveSelected from="target" to="source">
Remove selected
</ListCollectorMoveSelected>
</ListCollectorControls>
<ListCollectorPanel panel="target">
<ListCollectorHeader>Selected</ListCollectorHeader>
<ListCollectorSeparator />
<ListCollectorViewport>
<ListCollectorList>
{({ item, value }) => (
<ListCollectorItem value={value}>{item.label}</ListCollectorItem>
)}
</ListCollectorList>
</ListCollectorViewport>
</ListCollectorPanel>
</ListCollectorLayout>
</ListCollectorRoot>;When to use
- Moving items between two lists where users need to see both the available and selected sets at once.
- Column pickers, role assignments, or feature toggles where order in the target panel matters.
- Flows that benefit from multi-select transfer, optional per-panel search, or drag-and-drop reordering.
When not to use
- Single-list multi-select — use Checkbox list or Combobox with
multiple. - Hierarchical pickers — use Tree or a registry tree recipe.
- Very large flat lists without custom windowing — install
@by-es/virtual-list-collector(see Registry examples) or composeuseListCollectorPanelStatewith your own virtualizer.
Examples
Overview
Dual-panel transfer list with per-panel search, Add All / Clear All header actions, chevron transfer buttons, and contextual empty / no-results slots.
Draggable reordering
Set draggable on ListCollectorRoot and provide a drag affordance inside each ListCollectorItem. Rows reorder within a panel and move between panels via pointer drag-and-drop.
API Reference
Titles name the exports from @by/experience-system. ListCollector is a Blue Yonder composition primitive — there is no upstream Radix or Shadcn API. The tables below mirror the full public surface in ListCollector.tsx.
ListCollectorRoot
| Prop | Type | Default |
|---|---|---|
items | T[] | — |
getItemKey | (item: T) => string | — |
getItemText | (item: T) => string | — |
defaultValue | string[] | — |
value | string[] | — |
onValueChange | (selectedKeys: string[]) => void | — |
onTargetItemsChange | (items: T[]) => void | — |
onSourceItemsChange | (items: T[]) => void | — |
draggable | boolean | false |
size | 'sm' | 'md' | 'md' |
className | string | — |
children | React.ReactNode | — |
Generic T must extend Record<string, unknown>.
ListCollectorLayout
Extends div props.
| Prop | Type | Default |
|---|---|---|
panelWidth | number | — (640 px breakpoint when omitted) |
className | string | — |
| Data attribute | Values |
|---|---|
data-stacked | present when panels stack vertically on narrow viewports |
ListCollectorPanel
Extends div props.
| Prop | Type | Default |
|---|---|---|
panel | 'source' | 'target' | — |
className | string | — |
style | React.CSSProperties | — |
| Data attribute | Values |
|---|---|
data-lc-panel | 'source' | 'target' when draggable is enabled on ListCollectorRoot |
ListCollectorHeader
Extends div props. Flex header row inside a panel.
| Data attribute | Values |
|---|---|
data-slot | list-collector-header |
ListCollectorSeparator
Extends div props. Horizontal rule inside a panel.
| Data attribute | Values |
|---|---|
data-slot | list-collector-separator |
ListCollectorViewport
Extends div props. Scrollable list region.
| Data attribute | Values |
|---|---|
data-slot | list-collector-viewport |
ListCollectorFilter
Render-prop bridge for per-panel text filtering. Renders no UI — bring your own input.
| Prop | Type | Default |
|---|---|---|
children | (ctx: { query: string; setQuery: (query: string) => void; clearQuery: () => void }) => React.ReactNode | — |
Filtering matches getItemText case-insensitively on the panel's current items.
ListCollectorList
Extends div props except children and role.
| Prop | Type | Default |
|---|---|---|
children | (ctx: { item: T; value: string; selected: boolean }) => React.ReactNode | — |
className | string | — |
aria-label | string | — (recommended) |
Renders role="listbox" with aria-multiselectable="true". Iterates filtered items for the surrounding panel.
ListCollectorItem
Extends div props except role, aria-selected, and children.
| Prop | Type | Default |
|---|---|---|
value | string | — |
asChild | boolean | false |
disabled | boolean | false |
className | string | — |
children | React.ReactNode | — |
Also forwards standard event handlers (onClick, onKeyDown, onMouseDown, …). Caller handlers run first; built-in selection is skipped when preventDefault() is called on click or keydown. style merges over internal drag spacing for virtualized rows.
| Data attribute | Values |
|---|---|
data-selected | 'true' when selected |
data-disabled | 'true' when disabled |
data-draggable | 'true' when drag is enabled |
data-lc-item-key | item key when drag is enabled |
ARIA: role="option", aria-selected, aria-disabled, tabIndex.
ListCollectorControls
Extends div props. Wrapper for transfer buttons; orientation follows layout stacking.
| Data attribute | Values |
|---|---|
data-slot | list-collector-controls |
ListCollectorMoveSelected
| Prop | Type | Default |
|---|---|---|
from | 'source' | 'target' | — |
to | 'source' | 'target' | — |
asChild | boolean | false |
children | React.ReactNode | — |
Auto-disabled when the source panel has no selected items. Default element is button with type="button".
ListCollectorMoveAll
| Prop | Type | Default |
|---|---|---|
from | 'source' | 'target' | — |
to | 'source' | 'target' | deprecated — destination is always the opposite panel |
asChild | boolean | false |
children | React.ReactNode | — |
Auto-disabled when the source panel is empty.
ListCollectorEmpty
| Prop | Type | Default |
|---|---|---|
panel | 'source' | 'target' | — |
children | React.ReactNode | — |
Renders only when the panel has zero items (ignores active filter).
ListCollectorNoResults
| Prop | Type | Default |
|---|---|---|
panel | 'source' | 'target' | — |
children | React.ReactNode | — |
Renders only when the panel has items but the active filter matches none.
useListCollectorPanelState
Hook for reading panel state without ListCollectorList — use when windowing rows with a virtualizer.
const { items, selectedKeys, toggleSelection, query, isEmpty, hasNoResults, size } =
useListCollectorPanelState(panel?);| Return | Type | Description |
|---|---|---|
items | T[] | Filtered items for the panel |
selectedKeys | ReadonlySet<string> | Selected keys in the panel |
toggleSelection | (key: string) => void | Toggle selection for a key |
query | string | Active filter query |
isEmpty | boolean | Panel has no items |
hasNoResults | boolean | Items exist but filter matches none |
size | 'sm' | 'md' | Row density from ListCollectorRoot |
Optional panel argument defaults to the surrounding ListCollectorPanel.
useListCollectorDragState
Hook for drag UI when draggable is enabled.
| Return | Type | Description |
|---|---|---|
draggingKey | string | null | Key of the item being dragged |
draggingItemHeight | number | Measured height of the dragged row |
dragOverInfo | { panelId: 'source' | 'target'; itemKey: string | null } | null | Drop target under the cursor |
Accessibility
Each panel list uses role="listbox" with aria-multiselectable="true"; rows are role="option" with aria-selected. Provide an accessible name for each list via aria-label on ListCollectorList (or aria-labelledby pointing at panel header text). Pair filter inputs with aria-label or visible labels. Empty and no-results overlays should use concise, descriptive text — the registry recipes use Empty for this pattern.
When draggable is enabled, include a visible drag affordance and do not rely on drag alone for transfer — keep ListCollectorMoveSelected / keyboard selection available. Virtualized registry rows set aria-setsize and aria-posinset from the full dataset so assistive tech reports the true list size; see Foundation → Virtualization.
Keyboard interactions
| Key | Description |
|---|---|
Tab | Moves focus between ListCollectorItem rows (tabIndex={0} when enabled). |
Enter / Space | Toggles selection on the focused ListCollectorItem. |
| Pointer drag | When draggable is enabled, reorders within a panel or moves rows between panels. |
Source in the repo: packages/experience-system/src/components/ListCollector/ListCollector.tsx. Agent-oriented contracts: packages/experience-system/src/components/ListCollector/ListCollector.instructions.md.
Registry examples
These @by-es items are registry-only composites built on ListCollector primitives. Live previews use the same sources as the Experience System registry; View code shows post-shadcn add imports (typically @/components/ui/...). See Registry for components.json and REGISTRY_TOKEN.
List Collector
Dual-panel transfer list with selection, Add All / Clear All, optional search, drag-and-drop reordering, empty/no-results slots, and a renderItem render prop.
After shadcn add, import ListCollector from your registry path (for example @/components/ui/list-collector). Use View code on the preview above for a copy-pasteable snippet.
Run the shadcn CLI with your package manager. It vendors the recipe into components/ui/list-collector.tsx and installs the runtime dependencies (@by/experience-system, …) automatically:
pnpm dlx shadcn@latest add @by-es/list-collectorIn this monorepo, depend on the workspace package (for example via workspace:* or your catalog) so imports resolve to packages/experience-system. Configure the Registry in your app before adding recipes.
Virtual List Collector
Virtualized dual-panel transfer list for large item sets (500+ rows per panel). Windows rows with @tanstack/react-virtual while ListCollectorRoot stays the single source of truth for panel membership, selection, and search. Rows are real ListCollectorItem primitives with aria-setsize / aria-posinset from the full data set.
After shadcn add, import VirtualListCollector from your registry path (for example @/components/ui/virtual-list-collector).
Run the shadcn CLI with your package manager. It vendors the recipe into components/ui/virtual-list-collector.tsx and installs the runtime dependencies (@by/experience-system, …) automatically:
pnpm dlx shadcn@latest add @by-es/virtual-list-collectorIn this monorepo, depend on the workspace package (for example via workspace:* or your catalog) so imports resolve to packages/experience-system. Configure the Registry in your app before adding recipes.
Requires @tanstack/react-virtual alongside @by/experience-system.