Skip to content

Sortable List

Drag, Drop and InteractionSince 0.3.0
import { SortableList } from '@absoluteui/components/sortable-list';
import '@absoluteui/components/sortable-list/style.css';

Using Angular? See Sortable List for Angular.

A labelled, vertical list that users reorder. Two equivalent input paths:

  • Pointer drag through pointer events (mouse, pen, touch), from the handle or, with dragFrom: 'item', from anywhere on the item. HTML5 drag and drop is never used.
  • Keyboard on each item’s handle button: Space or Enter grabs, arrow keys, Home, and End move, Space or Enter drops, Escape or leaving the handle cancels.

Every step is announced through the assertive live region. The moved item never leaves the DOM (its siblings move around it), so focus and pointer capture survive the reorder. The new order is reported with aui-change; persisting it is the consumer’s job.

  • Letting users set the order of a short list, such as priorities, playlist tracks, or dashboard widgets.
  • Ordering that must be operable by keyboard, pointer, and touch alike.
  • Sorting data by a column value: use Data Table sorting.
  • Moving items between two lists (for example, available and selected): use a transfer list pattern; drag between lists is not supported.
  • Grids or horizontal layouts: only vertical lists are supported.
  • Very long lists where users must scroll while dragging: the list does not auto-scroll; offer move-to-position controls instead.
  • Choosing one item: use a radio group or listbox.

Reorder priorities and keep the options in sync

Section titled “Reorder priorities and keep the options in sync”
import { SortableList, type SortableListChangeDetail, type SortableListItem } from '@absoluteui/components/sortable-list';
const host = document.querySelector<HTMLElement>('#priorities');
if (host) {
const list = SortableList.create(host, {
label: 'Priorities',
items: [
{ id: 'security', label: 'Security review' },
{ id: 'performance', label: 'Performance' },
{ id: 'docs', label: 'Documentation' },
],
});
host.addEventListener('aui-change', (event) => {
const { order } = (event as CustomEvent<SortableListChangeDetail>).detail;
const byId = new Map(list.options.items.map((item): [string, SortableListItem] => [item.id, item]));
// Feed the new order back so later updates and config changes keep it.
list.update({ items: order.flatMap((id) => byId.get(id) ?? []) });
// persist `order` here
});
}

Rich item content, a pinned item, and dragging from the whole item

Section titled “Rich item content, a pinned item, and dragging from the whole item”
import { SortableList, type SortableListItem } from '@absoluteui/components/sortable-list';
const card = (title: string, owner: string): HTMLElement => {
const el = document.createElement('div');
const strong = document.createElement('strong');
strong.textContent = title;
el.append(strong, ` - ${owner}`);
return el;
};
const items: SortableListItem[] = [
{ id: 'notes', label: 'Release notes', content: card('Release notes', 'Ana'), disabled: true },
{ id: 'api', label: 'API review', content: card('API review', 'Ben') },
{ id: 'qa', label: 'QA pass', content: card('QA pass', 'Chen') },
];
const host = document.createElement('div');
document.body.append(host);
SortableList.create(host, { label: 'Backlog', items, dragFrom: 'item' });
import { SortableList } from '@absoluteui/components/sortable-list';
const host = document.createElement('div');
document.body.append(host);
const tracks = SortableList.create(host, {
label: 'Playlist',
items: [
{ id: 't1', label: 'Intro' },
{ id: 't2', label: 'Theme' },
{ id: 't3', label: 'Finale' },
],
});
document.querySelector('#finale-first')?.addEventListener('click', () => {
if (!tracks.isDragging) tracks.move('t3', 0);
const order: string[] = tracks.order;
host.dataset.order = order.join(',');
});
import { SortableList, type SortableListChangeDetail } from '@absoluteui/components/sortable-list';
import '@absoluteui/components/sortable-list/style.css';
const host = document.querySelector<HTMLElement>('#priorities');
if (host) {
SortableList.create(host, {
label: 'Priorities',
items: [
{ id: 'security', label: 'Security review' },
{ id: 'performance', label: 'Performance' },
],
});
host.addEventListener('aui-change', (event) => {
const { order } = (event as CustomEvent<SortableListChangeDetail>).detail;
// persist `order`
});
}
import { SortableList } from '@absoluteui/components/sortable-list';
const host = document.createElement('div');
document.body.append(host);
SortableList.create(host, {
label: 'Priorities',
items: [
{ id: 'security', label: 'Security review' },
{ id: 'performance', label: 'Performance' },
{ id: 'docs', label: 'Documentation', disabled: true },
],
});
<div class="aui-sortable-list [aui-sortable-list--disabled] [aui-sortable-list--drag-item]"> (consumer host)
<ul class="aui-sortable-list__list" aria-label="{label}">
<li class="aui-sortable-list__item [--grabbed] [--dragging] [--disabled]" data-id="{id}"
style="--aui-sortable-list-offset (CSSOM, pointer drag only)">
<button type="button" class="aui-sortable-list__handle" aria-label="Reorder {label}"
aria-pressed="false|true" aria-describedby="{instructions id}" disabled?>
<svg class="aui-icon" aria-hidden="true"> (icon 'menu')
</button>
<div class="aui-sortable-list__content">content or label text</div>
</li>
...
</ul>
<p class="aui-sortable-list__instructions" id>visually hidden instructions</p>
</div>
SortableList.create(host: HTMLElement, options: SortableListOptions): SortableList

Global configuration key: sortableList (see Configuration).

Option Type Default Description
label string Accessible name of the list (aria-label on the <ul>). Must be non-empty after trimming. Required.
items readonly { id: string; label: string; content?: Node | string; disabled?: boolean }[] Items in order. Ids must be unique, non-empty, and stable; labels non-empty (used in the handle name and announcements). Rows are kept by id on update; the array order defines the DOM order. Required.
disabled boolean false Disable reordering for the whole list (all handles disabled).
icons Partial<{ handle: IconRef }> Icon slot overrides.
dragFrom 'handle' | 'item' "handle" Where a pointer drag can start: the handle only, or anywhere on a movable item (interactive content inside the item keeps its own pointer behavior). Globally configurable.
Property Type Description
host HTMLElement Consumer host; its children are owned by the component.
list HTMLUListElement The <ul> element.
order string[] Item ids in the current DOM order.
isDragging boolean Whether an item is grabbed (keyboard) or dragged (pointer).
options Readonly<SortableListOptions> Current instance options (items keep the last order passed in, not the DOM order).
Method Description
move(id: string, index: number): void Move an item to a zero-based index; cancels an active grab or drag first. No event, no announcement. Throws SORTABLE_LIST_OPTIONS for an unknown id or an index outside 0..count-1.
update(options: Partial<SortableListOptions>): void Merge, validate, and apply options; cancels an active grab or drag. Rows and content nodes are kept by id; content is re-inserted only when it changed.
destroy(): void Cancel any grab or drag, remove rendering, listeners, drag tracking, and subscriptions, and clear the host’s children and classes.
Event Detail Bubbles Cancelable Description
aui-change { order: string[]; id: string; from: number; to: number } Yes No Dispatched on the host after a keyboard or pointer drop that changed the item’s position. from and to are zero-based. Not emitted for cancels, drops in place, move(), or update().
Area Description
item content Each item’s content (Node or text) or, without it, its label text, inside .aui-sortable-list__content.

All rules live in the aui.components cascade layer, so application styles override them without specificity battles.

Class Purpose
aui-sortable-list Host.
aui-sortable-list--disabled Host modifier: the whole list is disabled.
aui-sortable-list--drag-item Host modifier: dragFrom is ‘item’.
aui-sortable-list__list The <ul> (grid with space-2 gap).
aui-sortable-list__item One <li> per item; data-id holds the item id.
aui-sortable-list__item--grabbed The grabbed or dragged item.
aui-sortable-list__item--dragging The item is following the pointer (translateY by –aui-sortable-list-offset).
aui-sortable-list__item--disabled The item cannot be moved.
aui-sortable-list__handle Handle <button> with the grip icon.
aui-sortable-list__content Item content wrapper.
aui-sortable-list__instructions Visually hidden instructions referenced by every handle’s aria-describedby.
--aui-sortable-list-offset Custom property set through the CSSOM during a pointer drag: vertical offset in px.
Slot Default icon
handle menu

Tokens this stylesheet reads (verified in CI). Override them in a theme or with the Theme Designer.

--aui-border-width | --aui-color-border | --aui-color-focus-ring | --aui-color-hover | --aui-color-selected | --aui-color-selected-text | --aui-color-surface | --aui-color-text | --aui-color-text-disabled | --aui-color-text-muted | --aui-control-height-sm | --aui-focus-ring-offset | --aui-focus-ring-width | --aui-font-family-sans | --aui-font-size-md | --aui-icon-size-md | --aui-line-height-normal | --aui-radius-md | --aui-radius-sm | --aui-shadow-lg | --aui-space-1 | --aui-space-2 | --aui-space-3

type SortableListDragFrom = 'handle' | 'item';
interface SortableListConfig { dragFrom: SortableListDragFrom }
interface SortableListItem { id: string; label: string; content?: Node | string; disabled?: boolean }
interface SortableListIcons { handle: IconRef }
interface SortableListOptions extends Partial<SortableListConfig> {
label: string;
items: readonly SortableListItem[];
disabled?: boolean;
icons?: Partial<SortableListIcons>;
}
interface SortableListChangeDetail { order: string[]; id: string; from: number; to: number }

Pattern: Reorderable list with a toggle-button grab handle per item and live-region announcements (keyboard drag-and-drop pattern). Native list semantics; no ARIA drag-and-drop attributes (aria-grabbed and aria-dropeffect are deprecated).

Key Context Action
Tab / Shift+Tab Not grabbed Moves between handle buttons (one per enabled item) and interactive item content in document order.
Space / Enter Handle focused, nothing grabbed, item movable Grabs the item (aria-pressed=true); announces ‘Grabbed {label}. Current position {position} of {count}.’
Space / Enter Grabbed item’s handle focused Drops the item; announces ‘Dropped {label} at position {position} of {count}.’; emits aui-change when the position changed.
ArrowUp / ArrowLeft Item grabbed Moves the item one position toward the start (stops at the first); announces ‘{label} moved to position {position} of {count}.’
ArrowDown / ArrowRight Item grabbed Moves the item one position toward the end (stops at the last); announces the new position.
Home / End Item grabbed Moves the item to the first / last position.
Escape Item grabbed Cancels: the item returns to its original position; announces ‘Reorder canceled. {label} returned to position {position} of {count}.’ Consumed before the overlay stack, so an enclosing dialog stays open.
Escape Pointer drag in progress Cancels the drag and restores the original order; consumed before the overlay stack.
Tab / Shift+Tab Item grabbed Focus leaves the handle, which cancels the reorder (announced).
Other keys Item grabbed Swallowed (preventDefault) so they do not scroll or type. Keys with Alt, Ctrl, or Meta are ignored by the component.
Element Role
<ul> list (native)
<li> listitem (native)
handle <button> button (native, toggle through aria-pressed)
instructions <p> none (visually hidden description)
Attribute Element Value
aria-label <ul> label option
aria-label handle Localized ‘Reorder {label}’
aria-pressed handle true while the item is grabbed or dragged, false otherwise
aria-describedby handle id of the visually hidden instructions paragraph
disabled handle Native disabled for disabled items or a disabled list (removed from the tab order)
live region shared assertive live region (data-aui-live-region=‘assertive’) Grabbed / moved / dropped / canceled announcements

Focus stays on the grabbed item’s handle throughout a keyboard move because the item element never leaves the DOM (its siblings are moved around it). Leaving the handle (Tab, click elsewhere, window blur) cancels the reorder. Disabled handles are not focusable.

Criterion Level Note
1.3.1 Info and Relationships A
2.1.1 Keyboard A
2.4.3 Focus Order A
2.4.7 Focus Visible AA
2.5.7 Dragging Movements AA
4.1.2 Name, Role, Value A
4.1.3 Status Messages AA

Automated test results: Accessibility and compliance.

  • Dragging is never the only way to reorder (WCAG 2.5.7): the keyboard path on the handle button works with a single pointer too (activate the handle, then use keys), and move() supports custom move-to controls.
  • The grabbed state is conveyed by aria-pressed, a selected background, and a border (not color alone); forced-colors mode uses Highlight.
  • Instructions are exposed as the handle’s accessible description, not repeated in every announcement.
  • Announcements go through the shared assertive live region, which follows an open modal dialog so messages inside dialogs are not lost.
  • Manual screen-reader verification (NVDA, JAWS, VoiceOver) is pending.
  • Some screen readers intercept Space, Enter, and arrow keys in browse mode; users may need focus/forms mode on the handle.
  • Pointer drags announce every position change assertively, which can be verbose.

idle, handle hover, handle focus-visible, grabbed (keyboard), dragging (pointer), item disabled, list disabled.

From Trigger To
idle Space / Enter on a movable item’s handle grabbed (announced “Grabbed …”)
grabbed Arrow / Home / End grabbed at the new position (announced “… moved to position …”)
grabbed Space / Enter idle (announced “Dropped …”; aui-change when the position changed)
grabbed Escape, or focus leaves the handle idle at the original position (announced “Reorder canceled …”)
idle pointerdown on the handle (or item with dragFrom: 'item'), then a move of more than 4 px dragging (announced “Grabbed …”)
dragging the item’s middle crosses a sibling’s middle dragging at the new position (announced)
dragging pointer release idle (announced “Dropped …”; aui-change when the position changed)
dragging Escape, pointercancel, lost pointer capture idle at the original position (announced “Reorder canceled …”)
grabbed / dragging move(), update(), destroy() idle; the item returns to its original position first (no announcement)
  • Focus stays on the grabbed handle during a keyboard move because the item element never leaves the DOM.
  • Dragging is not required (WCAG 2.5.7): a single-pointer user can activate the handle and use the keys, or the consumer can offer move()-based controls.
  • The grabbed state is conveyed by aria-pressed, a selected background, and a border, not color alone. Forced-colors mode uses Highlight / HighlightText.
  • The live region follows an open modal dialog, so announcements inside dialogs are not lost.
  • Messages are registered under sortableList (instructions, handle, grabbed, moved, dropped, canceled) and can be localized.
  • Manual screen-reader verification is pending.

Items fill the list width; content wraps with overflow-wrap: anywhere. The handle is a control-height-sm square with touch-action: none, so touch drags from the handle never scroll the page, while touching item content still scrolls. With dragFrom: 'item', movable items also get touch-action: none: the page cannot be scrolled by touching them. During a pointer drag the item is clamped to the list’s visible bounds; there is no auto-scroll.

Logical properties throughout; the handle sits at the inline start. The list is vertical, so direction does not change reordering: ArrowUp and ArrowLeft always move toward the start of the list in both LTR and RTL.

Token-driven item surface, border, and radius; color-selected/color-selected-text for the grabbed item; shadow-lg while following the pointer; color-hover on the handle; color-text-disabled for disabled handles and color-text-muted for disabled item content. Forced-colors mode uses CanvasText borders, Highlight for the grabbed item, and GrayText for disabled handles.

configureAbsoluteUI({ components: { sortableList: { dragFrom: 'item' } } });

Globally configurable: dragFrom. Changes apply to existing instances immediately.

Instance options override global and theme defaults. All options are dynamic.

icons.handle (default menu), resolved through the global icon provider/registry. The icon is decorative; the handle’s name comes from aria-label.

The list label, item labels, announcements, and string content use textContent. No HTML parsing. The drag offset is written as a CSS custom property through the CSSOM, so the component is CSP-safe.

No persistence, network access, or telemetry. aui-change reports the new order; the consumer stores it.

Two listeners on the list (keydown, focusout), one pointer-drag tracker, and one configuration subscription per instance. A keyboard move moves the item by moving its siblings (one DOM move per step). During a pointer drag each move reads the bounding boxes of the list, the item, and the siblings it crosses. Re-renders reuse rows by id and replace the list’s children only when the order differs. Budgets in headless Chromium: SortableList.create with 500 items under 100 ms; one keyboard move with 500 items under 16 ms.

Budgeted with 500 items. Because there is no auto-scroll, pointer reordering is practical only within the visible part of the list; for long lists rely on the keyboard path or move() controls. No virtualization.

  • Dropping at the original position announces “Dropped …” but emits no aui-change.
  • A disabled item cannot be grabbed or dragged; other items move past it.
  • With dragFrom: 'item', a press on interactive content in the item (a, button, input, select, textarea, label, [contenteditable]) does not start a drag.
  • A press without movement (under 4 px) is a click, not a drag; the click after a completed drag is suppressed.
  • A pointer drag cannot start while a keyboard grab is active.
  • Only the primary pointer and, for mouse, the main button start a drag.
  • A drop or move() also reorders the items option (options.items), so later re-renders keep the new order. update({ items }) with an older order restores that order: pass the order from aui-change back (frameworks with bound inputs must).
  • A re-render that changes the order keeps focus on the focused handle.
  • A global configuration change cancels an active grab or drag.
  • An empty items array renders an empty, labelled list.
Code Category Cause
SORTABLE_LIST_OPTIONS configuration Blank label; items not an array; an item that is not an object; empty or duplicate id; blank item label; content that is not a Node or string; non-boolean disabled; move() with an unknown id or an out-of-range index.
CFG_INVALID_VALUE / CFG_UNKNOWN_OPTION configuration Invalid or unknown configuration value, such as dragFrom: 'anywhere'.

A failed construction leaves nothing behind: the host’s children and classes are untouched and no listeners or subscriptions remain. A failed update() keeps the previous options and rendering.

  • Vertical lists only.
  • No auto-scroll during a pointer drag.
  • No drag between lists, no nesting, no multi-select.
  • update({ items }) with a stale order undoes the user’s reorder; feed the order from aui-change back.
  • Pointer drags announce every position change, which can be verbose.
  • Manual screen-reader verification is pending.
  • HTML5 drag and drop (draggable, dataTransfer), including dropping files or external data.
  • Horizontal or grid layouts.
  • Dragging between lists or into nested lists.
  • HTML strings as labels or content: pass a Node instead.