SortableList
A composable drag-and-drop system using @dnd-kit. Supports up to 3 levels of nesting, item and group drag handles, external drag sources (DragSource), and per-group callbacks for reorder, cross-group moves, and external drops.
Overview
The SortableList system provides composable drag-and-drop reordering for
nested structures up to 3 levels deep. Groups can be reordered within the
list, items can be reordered within a group, items can be moved between
groups, and external DragSource elements can be dropped into groups
to create new items.
Component Hierarchy
SortableList
SortableGroup (level 1)
SortableGroupHandle — dedicated group drag handle
SortableItem (level 2)
SortableItemHandle — dedicated item drag handle (optional)
SortableGroup (level 3 — nested, depth=1)
SortableItem (level 3 items)
DragSource — external drag element (side panel) Props
SortableList
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | Required. Unique ID for this list |
items | string[] | — | Required. Ordered array of group IDs |
children | ReactNode | — | SortableGroup children |
onReorder | (items: string[]) => void | — | Called with reordered group IDs when groups are reordered |
renderDragOverlay | (activeId: string, sourceData?: Record<string, unknown>)
=> ReactNode | — | Render a custom drag overlay. Receives sourceData for
DragSource drags. |
SortableGroup
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | Required. Unique ID for this group |
index | number | — | Required. Position index within the parent list |
items | string[] | — | Required. Ordered array of child item IDs |
children | ReactNode | — | Group content: handles, items, and free consumer children (title rows, toggles, etc.) |
depth | number | auto | Current depth (0 = root, 1 = nested). Auto-detected if omitted. Throws in dev if > 1. |
onReorderItems | (items: string[]) => void | — | Called with new item order when items within this group are reordered |
onReceiveItem | (itemId: string, fromGroupId: string, insertIndex: number)
=> void | — | Called when an existing item arrives from another group |
onReceiveExternal | (sourceId: string, data: Record<string, unknown>,
insertIndex: number) => void | — | Called when a DragSource is dropped into this group |
SortableItem
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | Required. Unique identifier for this item |
index | number | — | Required. Position index within the parent container |
children | ReactNode | — | Content to render. May include a SortableItemHandle to
isolate drag activation. |
SortableGroupHandle / SortableItemHandle
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Required. Handle content — typically an Icon provided by the consumer |
SortableGroupHandle must be inside a SortableGroup.
SortableItemHandle must be inside a SortableItem. Both throw if used outside their parent context.
DragSource
| Prop | Type | Default | Description |
|---|---|---|---|
sourceId | string | — | Required. Type ID of the element (not the instance) |
data | Record<string, unknown> | — | Required. Arbitrary data copied when creating the item at the destination |
used | boolean | false | If true, applies a "used" visual indicator (reduced opacity). Element remains draggable. |
children | ReactNode | — | Required. Draggable content |
Drag-and-Drop Behavior
DragOverlay
Always visible during drag. If the consumer passes renderDragOverlay, that function is used. Otherwise, the library generates a default
overlay by cloning the active element's DOM node.
DragSource (side panel → main area)
- User drags a
DragSourceelement from the side panel. - DragOverlay shows a visual copy following the cursor.
-
When entering a
SortableGroup, insertion position is detected. -
On drop,
onReceiveExternal(sourceId, data, insertIndex)fires on the target group. - The consumer generates a new unique ID and adds the item to state.
-
The original
DragSourceis NOT moved — it creates a copy.
Reorder within a group
Drag a SortableItem (from its handle if present, or the whole item).
On drop over another item in the same group, onReorderItems(newOrder) fires.
Cross-container move
Drag a SortableItem outside its current group and into another.
On drop, onReceiveItem(itemId, fromGroupId, insertIndex) fires
on the target group. The consumer removes from source and adds to target in
a single setState.
Handle isolation
-
SortableGroupHandle— only the handle activates group drag. The group wrapper has no drag listeners. -
SortableItemHandle— only the handle activates item drag. If not used, the entire item is draggable. - Dragging an item MUST NEVER activate the drag of its parent group.
3-Level Nesting
A SortableItem may contain a nested SortableGroup
to create a third level. Use depth={1} on the nested
group. Maximum depth is 1 — exceeding it throws a dev-only error.
Code
<SortableList id="root" items={groupIds}>
<SortableGroup id="group-1" index={0} items={itemIds} depth={0}>
<SortableGroupHandle><Icon name="grip-vertical" /></SortableGroupHandle>
<SortableItem id="item-a" index={0}>
<SortableItemHandle><Icon name="grip-vertical" /></SortableItemHandle>
<span>Item A</span>
{/* Level 3 — nested group inside an item */}
<SortableGroup id="sub-group" index={0} items={subItemIds} depth={1}>
<SortableItem id="sub-1" index={0}>
<span>Sub Item 1</span>
</SortableItem>
</SortableGroup>
</SortableItem>
</SortableGroup>
</SortableList> Examples
Basic Nested Usage with Handles
import {
SortableList, SortableGroup, SortableGroupHandle,
SortableItem, SortableItemHandle, arrayMove,
} from '@enara-health/ui-react';
<SortableList id="root" items={groupIds} onReorder={handleReorderGroups}>
{groups.map((group, i) => (
<SortableGroup
key={group.id} id={group.id} index={i}
items={group.itemIds}
onReorderItems={handleReorderItems(group.id)}
onReceiveItem={handleReceiveItem(group.id)}
onReceiveExternal={handleReceiveExternal(group.id)}
>
<SortableGroupHandle>
<Icon name="grip-vertical" />
</SortableGroupHandle>
<Text>{group.title}</Text>
{group.itemIds.map((itemId, j) => (
<SortableItem key={itemId} id={itemId} index={j}>
<Card>
<SortableItemHandle>
<Icon name="grip-vertical" size="sm" />
</SortableItemHandle>
<Text>{itemId}</Text>
</Card>
</SortableItem>
))}
</SortableGroup>
))}
</SortableList> DragSource (external drop)
{/* Side panel — DragSource creates copies, not moves */}
<DragSource
sourceId="photo-id"
data={{ label: 'Photo ID', icon: 'id-card' }}
used={usedIds.has('photo-id')}
>
<Card>
<Icon name="id-card" />
<Text>Photo ID</Text>
</Card>
</DragSource>
{/* Main area — group receives external drops */}
<SortableGroup
id="section-1"
items={itemIds}
onReceiveExternal={(sourceId, data, insertIndex) => {
const newId = generateId();
addItem(newId, data, insertIndex);
}}
>
...
</SortableGroup> Anti-Patterns
- Duplicate IDs across groups — IDs must be globally unique for @dnd-kit.
- SortableGroupHandle outside SortableGroup — throws an error.
- SortableItemHandle outside SortableItem — throws an error.
- Two separate setStates for cross-container moves — causes
items to disappear. Always use a single atomic setState in
onReceiveItem. - Nesting deeper than 3 levels — a
SortableGroupwith depth > 1 throws a dev-only error.
Accessibility
- Roles: List uses
role="list", items userole="listitem"witharia-roledescription="sortable item". Handles haverole="button"andtabIndex={0}. - Keyboard Support:
Spaceto pick up, arrow keys to move,Spaceto drop,Escapeto cancel. - Screen Reader: Announces drag instructions and position changes during drag operations.
- Touch Support: Touch-friendly drag interactions with 8px activation constraint to prevent accidental drags.
- Handle Isolation: SortableGroupHandle and SortableItemHandle isolate drag interaction from content, preventing accidental parent drags.
Best Practices
- Ensure each item and group has a globally unique ID
- Use handles when items contain interactive content (buttons, inputs)
- Provide
renderDragOverlayfor custom drag previews -
Use a single atomic
setStatefor cross-container operations