Experience System

List Collector

Composable dual-panel transfer list for moving items between source and target panels with selection, filtering, drag-and-drop, and empty states.

Available
List Item 1
List Item 2
List Item 3
List Item 4
List Item 5
List Item 6
List Item 7
List Item 8
List Item 9
List Item 10
List Item 11
List Item 12
List Item 13
List Item 14
List Item 15
Selected
Nothing selected yet
Please select an item from the left panel.

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-system

In 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 compose useListCollectorPanelState with 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.

Available
List Item 1
List Item 2
List Item 3
List Item 4
List Item 5
List Item 6
List Item 7
List Item 8
List Item 9
List Item 10
List Item 11
List Item 12
List Item 13
List Item 14
List Item 15
Selected
Nothing selected yet
Please select an item from the left panel.

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.

Available
List Item 1
List Item 2
List Item 3
List Item 4
List Item 5
List Item 6
List Item 7
List Item 8
Selected

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

PropTypeDefault
itemsT[]
getItemKey(item: T) => string
getItemText(item: T) => string
defaultValuestring[]
valuestring[]
onValueChange(selectedKeys: string[]) => void
onTargetItemsChange(items: T[]) => void
onSourceItemsChange(items: T[]) => void
draggablebooleanfalse
size'sm' | 'md''md'
classNamestring
childrenReact.ReactNode

Generic T must extend Record<string, unknown>.

ListCollectorLayout

Extends div props.

PropTypeDefault
panelWidthnumber— (640 px breakpoint when omitted)
classNamestring
Data attributeValues
data-stackedpresent when panels stack vertically on narrow viewports

ListCollectorPanel

Extends div props.

PropTypeDefault
panel'source' | 'target'
classNamestring
styleReact.CSSProperties
Data attributeValues
data-lc-panel'source' | 'target' when draggable is enabled on ListCollectorRoot

ListCollectorHeader

Extends div props. Flex header row inside a panel.

Data attributeValues
data-slotlist-collector-header

ListCollectorSeparator

Extends div props. Horizontal rule inside a panel.

Data attributeValues
data-slotlist-collector-separator

ListCollectorViewport

Extends div props. Scrollable list region.

Data attributeValues
data-slotlist-collector-viewport

ListCollectorFilter

Render-prop bridge for per-panel text filtering. Renders no UI — bring your own input.

PropTypeDefault
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.

PropTypeDefault
children(ctx: { item: T; value: string; selected: boolean }) => React.ReactNode
classNamestring
aria-labelstring— (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.

PropTypeDefault
valuestring
asChildbooleanfalse
disabledbooleanfalse
classNamestring
childrenReact.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 attributeValues
data-selected'true' when selected
data-disabled'true' when disabled
data-draggable'true' when drag is enabled
data-lc-item-keyitem 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 attributeValues
data-slotlist-collector-controls

ListCollectorMoveSelected

PropTypeDefault
from'source' | 'target'
to'source' | 'target'
asChildbooleanfalse
childrenReact.ReactNode

Auto-disabled when the source panel has no selected items. Default element is button with type="button".

ListCollectorMoveAll

PropTypeDefault
from'source' | 'target'
to'source' | 'target'deprecated — destination is always the opposite panel
asChildbooleanfalse
childrenReact.ReactNode

Auto-disabled when the source panel is empty.

ListCollectorEmpty

PropTypeDefault
panel'source' | 'target'
childrenReact.ReactNode

Renders only when the panel has zero items (ignores active filter).

ListCollectorNoResults

PropTypeDefault
panel'source' | 'target'
childrenReact.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?);
ReturnTypeDescription
itemsT[]Filtered items for the panel
selectedKeysReadonlySet<string>Selected keys in the panel
toggleSelection(key: string) => voidToggle selection for a key
querystringActive filter query
isEmptybooleanPanel has no items
hasNoResultsbooleanItems 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.

ReturnTypeDescription
draggingKeystring | nullKey of the item being dragged
draggingItemHeightnumberMeasured height of the dragged row
dragOverInfo{ panelId: 'source' | 'target'; itemKey: string | null } | nullDrop 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

KeyDescription
TabMoves focus between ListCollectorItem rows (tabIndex={0} when enabled).
Enter / SpaceToggles selection on the focused ListCollectorItem.
Pointer dragWhen 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.

Available
List Item 1
List Item 2
List Item 3
List Item 4
List Item 5
List Item 6
List Item 7
List Item 8
List Item 9
List Item 10
List Item 11
List Item 12
List Item 13
List Item 14
List Item 15
Selected
Nothing selected yet
Please select an item from the left panel.

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-collector

In 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.

Available columns
Selected columns

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-collector

In 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.