Drag and drop
createDragReorder turns a flat list of sibling elements (<li>s, table
rows, cards, scheduler entries) into a reorderable one: press and drag an
item (or a handle inside it) past a neighbor to swap places, drop it, and
get told the new order. It's a plain, framework-agnostic DOM utility, not a
custom element: it moves the real nodes it's given and reports back
through callbacks, it never renders anything of its own.
npm install @eunomia/elements
import { createDragReorder } from "@eunomia/elements";
Basic usage
Grab a row by its handle (the grip icon) and drag it up or down, or tab to a handle and press Space, then the arrow keys, then Space again:
-
Design review -
Write release notes -
Fix flaky test -
Ship changelog
Like debounce/throttle on the
destroy())
when the list itself goes away, not recreated on every render. Pick a
framework in the toolbar above to see where each one hooks in:
import { createDragReorder } from "@eunomia/elements";
const list = document.querySelector<HTMLUListElement>("#task-list")!;
const controller = createDragReorder(list, {
items: () => Array.from(list.children) as HTMLElement[],
handle: ".drag-handle",
onReorder: ({ fromIndex, toIndex }) => {
tasks.splice(toIndex, 0, tasks.splice(fromIndex, 1)[0]);
},
});
controller.destroy();
import { useEffect, useRef } from "react";
import { createDragReorder } from "@eunomia/elements";
function TaskList({ tasks, setTasks }: TaskListProps) {
const listRef = useRef<HTMLUListElement>(null);
const tasksRef = useRef(tasks);
tasksRef.current = tasks;
useEffect(() => {
const list = listRef.current;
if (!list) {
return;
}
const controller = createDragReorder(list, {
items: () => Array.from(list.children) as HTMLElement[],
handle: ".drag-handle",
onReorder: ({ fromIndex, toIndex }) => {
const next = [...tasksRef.current];
next.splice(toIndex, 0, next.splice(fromIndex, 1)[0]);
setTasks(next);
},
});
return () => controller.destroy();
}, []);
return (
<ul ref={listRef}>
{tasks.map((task) => (
<li key={task.id}>
<button className="drag-handle">⠿</button>
{task.label}
</li>
))}
</ul>
);
}
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { createDragReorder } from "@eunomia/elements";
const props = defineProps<{ tasks: Task[] }>();
const emit = defineEmits<{ (e: "update:tasks", tasks: Task[]): void }>();
const listEl = ref<HTMLUListElement>();
let controller: { destroy(): void } | undefined;
onMounted(() => {
const list = listEl.value;
if (!list) {
return;
}
controller = createDragReorder(list, {
items: () => Array.from(list.children) as HTMLElement[],
handle: ".drag-handle",
onReorder: ({ fromIndex, toIndex }) => {
const next = [...props.tasks];
next.splice(toIndex, 0, next.splice(fromIndex, 1)[0]);
emit("update:tasks", next);
},
});
});
onUnmounted(() => controller?.destroy());
</script>
<template>
<ul ref="listEl">
<li v-for="task in tasks" :key="task.id">
<button class="drag-handle">⠿</button>
</li>
</ul>
</template>
import {
AfterViewInit,
Component,
ElementRef,
EventEmitter,
Input,
OnDestroy,
Output,
ViewChild,
} from "@angular/core";
import { createDragReorder } from "@eunomia/elements";
@Component({
selector: "app-task-list",
template: `
<ul #list>
<li *ngFor="let task of tasks">
<button class="drag-handle">⠿</button>
</li>
</ul>
`,
})
export class TaskListComponent implements AfterViewInit, OnDestroy {
@Input() tasks: Task[] = [];
@Output() tasksChange = new EventEmitter<Task[]>();
@ViewChild("list") listRef!: ElementRef<HTMLUListElement>;
private controller?: { destroy(): void };
ngAfterViewInit() {
const list = this.listRef.nativeElement;
this.controller = createDragReorder(list, {
items: () => Array.from(list.children) as HTMLElement[],
handle: ".drag-handle",
onReorder: ({ fromIndex, toIndex }) => {
const next = [...this.tasks];
next.splice(toIndex, 0, next.splice(fromIndex, 1)[0]);
this.tasksChange.emit(next);
},
});
}
ngOnDestroy() {
this.controller?.destroy();
}
}
The reorder function
function createDragReorder(
container: HTMLElement,
options: DragReorderOptions,
): DragReorderController;
container is the shared parent of every element options.items()
returns (a <ul>, a eun-table, any wrapper). The returned controller has
a single method:
interface DragReorderController {
destroy(): void;
}
Call destroy() when the list is torn down (e.g. a Lit component's
disconnectedCallback) to remove the listeners this call installed.
Options
| Option | Type | Default | Description |
|---|---|---|---|
items |
() => HTMLElement[] |
— | Returns the current items, in order. Called at the start of every drag and after every swap, and every element it returns must be a direct child of container. |
orientation |
"vertical" | "horizontal" |
"vertical" |
Which axis neighbor swaps and arrow-key moves react to. |
handle |
string |
— | CSS selector for the element within each item that starts a drag. Omit to make the whole item draggable. |
disabled |
(item) => boolean |
— | Excludes an item as a drag source. Other items can still be swapped past it. |
activationDistance |
number |
4 |
Pointer distance, in px, before a press turns into a drag, so a plain click isn't swallowed. |
draggingAttribute |
string |
"data-dragging" |
Attribute toggled on an item for the duration of its drag, to style off of (see the demo above). |
scrollContainer |
HTMLElement | null |
nearest scrollable ancestor | Auto-scrolled when a pointer drag nears its edge. Pass null to disable auto-scroll. |
autoScrollEdge |
number |
48 |
Distance from scrollContainer's edge, in px, that triggers auto-scroll. |
autoScrollSpeed |
number |
12 |
Top auto-scroll speed, in px per frame. |
getItemLabel |
(item) => string |
aria-label, then text content |
Builds the screen-reader announcements a keyboard drag makes. |
onDragStart |
(detail) => void |
— | Called once, right as a drag (pointer or keyboard) activates. |
onMove |
(detail) => void |
— | Called every time the dragged item swaps with a neighbor, mid-drag. |
onReorder |
(detail) => void |
required | Called once a drag ends with the index actually changed (see below). |
onDragEnd |
(detail) => void |
— | Called once a drag ends, whether or not the order changed. |
onReorder's detail is { item, fromIndex, toIndex, order }, where
order is items()'s own next return value, captured for convenience. Since the DOM
is already in that order by the time this fires, updating whatever array
backs items() to match order is the only thing left to do: a keyed
repeat() (Lit), v-for/:key (Vue), or .map(x => ...) with a stable
key (React) render from that array reconciles against the DOM this
utility already put in place, instead of re-shuffling it.
Dragging an item between two different containers, and virtualized lists: every swap re-measures a real neighbor via getBoundingClientRect(), fine up to a few hundred items, not built for thousands.
Orientation
orientation: "horizontal" compares left/right instead of top/bottom, and
reacts to ←/→ instead of ↑/↓,
useful for a row of chips or tabs rather than a vertical list:
createDragReorder(tabStrip, {
items: () => Array.from(tabStrip.children),
orientation: "horizontal",
onReorder: ({ order }) => {
/* ... */
},
});
Accessibility
Every item (or its handle) needs to be a real focusable, interactive
element (a <button>, or anything with tabindex="0") for the built-in
keyboard support to reach it:
- Space or Enter picks the focused item up.
- ↑/↓ (or ←/→ for
orientation: "horizontal") swaps it with the neighbor in that direction. - Space or Enter again drops it in place.
- Escape cancels, returning it to where it started.
Each step is announced through a shared, visually-hidden aria-live
region: "Grabbed Design review, position 1 of 4. Use arrow keys to move,
space or enter to drop, escape to cancel.", then "Design review moved to
position 2 of 4." as it moves, then "Design review dropped, position 2 of
4." (or the cancellation message) once it ends.
A handle nested inside something with its own keyboard handling (a eun-table row, in particular) is the right call, not just a UX nicety: the handle's own keydown is what this utility listens for, and a table row's cell-to-cell arrow-key navigation only reacts when the event's target is one of its own cell wrappers directly. A handle button nested a level deeper never matches that, so the two never fight over the same arrow keys.
In practice
items() doesn't have to read container.children directly: anything
that returns the current items works, including a filtered
querySelectorAll for a container that also holds non-item children (a
table mixing in selection/skeleton rows, say). Three full, live examples,
each in its own feature's own docs:
Reordering rows using a small drag handle, kept clear of the grid's own keyboard navigation
Reordering items, and why a very large, virtualized list needs a different approach
Reordering a sidebar list of calendars, right alongside the calendar grid itself
That's about mapping a pointer position to a calendar cell, not reordering siblings, so it isn't what this utility does.
See also
Rate limiting :debounceandthrottle, this page's siblingMarkdown-lite :formatRichText, this page's other sibling