---
title: SortableController
description: 'Reorders items, or moves them between containers, by pointer or keyboard, with announcements.'
order: 13
---

import { PrimitiveExample } from '@/components/primitive-example';

export const config = {
	dependencies: {
		components: [PrimitiveExample],
		scripts: ['./sortable.script.ts'],
	},
};

# SortableController

<p class="docs-lede">Reorders items, or moves them between containers, by pointer or keyboard, with announcements.</p>

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

## Import

```ts
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.

<PrimitiveExample name="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>
</PrimitiveExample>

```html
<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>
```

```ts
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](/docs/controllers/overview#how-the-examples-run).

## Config

| Option              | Description                                                                                                                                   |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `getContainer`      | The element that holds the items; it receives the press and the keys.                                                                         |
| `getItems`          | The items in document order, read on every event.                                                                                             |
| `group`             | A `SortableGroup` shared by the containers that can exchange items. Without it items only reorder within the container.                       |
| `accepts`           | Refuses an item that comes from another container of the group.                                                                               |
| `getContainerLabel` | The name announced for this container. Default: its `aria-label`.                                                                             |
| `getHandle`         | Narrows where a press or Space lifts an item to a handle inside it.                                                                           |
| `liftKeys`          | The keys that lift an item. Default Space and Enter; drop is always Space or Enter.                                                           |
| `axis`              | `vertical` (default), `horizontal` or `both`.                                                                                                 |
| `threshold`         | Pointer travel in px before a press becomes a drag. Default `4`.                                                                              |
| `preview`           | Lifts the item into a layer that follows the pointer and keeps a placeholder in its slot. Default `false`; see [Drag preview](#drag-preview). |
| `getPreviewLayer`   | Where the preview goes. Default `document.body`. Only with `preview`.                                                                         |
| `createPlaceholder` | Builds the placeholder. Default an empty `div`; return an `li` for a `ul`. Only with `preview`.                                               |
| `getEnabled`        | Return `false` to ignore presses and keys.                                                                                                    |
| `getLabel`          | The name announced for an item. Default: its trimmed `textContent`.                                                                           |
| `announce`          | Receives each announcement; point it at a live region. Without it nothing is announced.                                                       |
| `messages`          | Replaces any of the announcements; see `DEFAULT_SORTABLE_MESSAGES`.                                                                           |
| `onChange`          | Called 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](/docs/controllers/bind-controller).

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

```ts
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.

```ts
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](/docs/recipes/board) 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`.

| Hook                                     | Description                                                                                                                                                                                                                                                                                              |
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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`. |
