SortableController

Reorders items, or moves them between containers, by pointer or keyboard, with announcements.

Reach for it for a reorderable list or board, including drag between lists.

Import

import { SortableController, SortableGroup, createSortableController } from '@ecopages/radiant-ui/controllers';

Try it

Drag an item, or focus it and press Space, move it with the arrow keys and press Space to drop. The controller never moves a node: onChange does, and the announcement goes to a live region.

  • Plan
  • Build
  • Ship

Focus an item, press Space, move with the arrows, press Space to drop

<div data-example="sortable">
	<ul>
		<li tabindex="0">Plan</li>
		<li tabindex="0">Build</li>
		<li tabindex="0">Ship</li>
	</ul>
	<p data-status aria-live="polite">Focus an item, press Space, move with the arrows, press Space to drop</p>
</div>
import { SortableController } from '@ecopages/radiant-ui/controllers';
 
export function mount(root: HTMLElement): () => void {
	const list = root.querySelector('ul')!;
	const status = root.querySelector('[data-status]')!;
 
	const sortable = new SortableController({
		getContainer: () => list,
		getItems: () => [...list.children] as HTMLElement[],
		announce: (message) => (status.textContent = message),
		onChange: ({ item, to }) => {
			const others = [...list.children].filter((child) => child !== item);
			list.insertBefore(item, others[to] ?? null);
			item.focus();
		},
	});
	sortable.attach();
	return () => sortable.destroy();
}

mount runs once for each example element on the page, and the function it returns undoes it; see how the examples run.

Config

OptionDescription
getContainerThe element that holds the items; it receives the press and the keys.
getItemsThe items in document order, read on every event.
groupA SortableGroup shared by the containers that can exchange items. Without it items only reorder within the container.
acceptsRefuses an item that comes from another container of the group.
getContainerLabelThe name announced for this container. Default: its aria-label.
getHandleNarrows where a press or Space lifts an item to a handle inside it.
liftKeysThe keys that lift an item. Default Space and Enter; drop is always Space or Enter.
axisvertical (default), horizontal or both.
thresholdPointer travel in px before a press becomes a drag. Default 4.
previewLifts the item into a layer that follows the pointer and keeps a placeholder in its slot. Default false; see Drag preview.
getPreviewLayerWhere the preview goes. Default document.body. Only with preview.
createPlaceholderBuilds the placeholder. Default an empty div; return an li for a ul. Only with preview.
getEnabledReturn false to ignore presses and keys.
getLabelThe name announced for an item. Default: its trimmed textContent.
announceReceives each announcement; point it at a live region. Without it nothing is announced.
messagesReplaces any of the announcements; see DEFAULT_SORTABLE_MESSAGES.
onChangeCalled on drop with { item, from, to, source, fromContainer, toContainer } for the owner to apply. Return false to refuse the move.

Lifecycle

attach() listens for presses and keys on the container and, with a group, joins it. destroy() ends a sort in progress without calling onChange or announcing, and leaves the group. While a sort runs the controller only sets state for CSS: data-sortable-state="lifted" on the item, data-sortable-drop="before" or "after" on the item it would land beside, data-sortable-active and data-sortable-over on containers, and --sortable-dx and --sortable-dy on the lifted item. After a drop the owner moves the node, which can blur it, so restore focus on the moved item.

With a host, createSortableController(host, config) does the same on connect and disconnect; see bindController.

Moving between lists

Create one SortableGroup, pass it as group to the controller of every container and insert into toContainer in onChange. A container with no items is a drop zone. PageDown and PageUp move the lifted item to the next and previous container of the group.

const group = new SortableGroup();
const sortable = (list: HTMLElement) => {
	const controller = new SortableController({
		group,
		getContainer: () => list,
		getItems: () => [...list.children] as HTMLElement[],
		onChange: ({ item, to, toContainer }) => {
			toContainer.insertBefore(item, [...toContainer.children].filter((el) => el !== item)[to] ?? null);
		},
	});
	controller.attach();
	return controller;
};

Drag preview

A card translated inside a scrolling or clipped column is cut off where it crosses into the next one, and nothing shows where it will land. With preview: true the lifted item leaves its container while it is sorted, goes through PortalController to getPreviewLayer() (default document.body) and follows the pointer with position: fixed, and a placeholder keeps its slot and moves to the target index, across the containers of a group. A keyboard lift shows the same: the item sits on the placeholder as the keys move it and keeps focus.

const sortable = new SortableController({
	preview: true,
	createPlaceholder: () => document.createElement('li'),
	getContainer: () => list,
	getItems: () => [...list.children] as HTMLElement[],
	onChange: ({ item, to }) => {
		const others = [...list.children].filter((child) => child !== item);
		list.insertBefore(item, others[to] ?? null);
		item.focus();
	},
});
  • The controller sets only geometry on the item (position, left, top, width, height, pointer-events, margin, box-sizing) and sizes the placeholder like the item. Colors, shadow and z-index of [data-sortable-state='lifted'] and the look of [data-sortable-placeholder] are your CSS. Styles that depend on an ancestor of the list no longer reach the lifted item, so select it by attribute.
  • onChange is unchanged. When the sort ends, by drop, cancel or destroy(), the item is back in its original place and the placeholder is gone before onChange runs, so the handler you wrote without preview keeps working. The controller never moves the item to its new place.
  • During the sort the placeholder is a child of the container and the lifted item is not. The controller reads getItems without both, so returning the live children is fine. Do not re-render or reorder the lists until the sort ends.
  • --sortable-dx and --sortable-dy are not set, and resolveTargetIndex receives from as null with the rects of the other items.

The board recipe moves cards between scrolling columns with it.

Protected hooks

Extend the class and override these to change how it decides. A component never extends a controller; a subclass is registered with bindController.

HookDescription
resolveTargetIndex(rects, from, point)Decides the index the lifted item takes from where the pointer is. Runs on every pointer move of a sort. Override to snap to a grid or change where a neighbour gives way. The default is resolveSortIndex and resolveInsertIndex with the configured axis. With preview, from is always null.