Eunomia v1.0.0-beta.2
AllAngularReactNext.jsJavaScriptVue
EunomiaSalmonForestVioletOceanGoldFireCustom…
🇬🇧 English🇫🇷 Français

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 Rate limiting page, this needs to be created exactly once, and torn down (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();

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:

Overview
Pricing
FAQ
Contact
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:

Table

Reordering rows using a small drag handle, kept clear of the grid's own keyboard navigation

Infinite list

Reordering items, and why a very large, virtualized list needs a different approach

Scheduler

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. The calendar solves that one natively instead, with its own pointer tracking purpose-built for the hour grid.

See also