@bro-code/drag-it
v2026.0.0
Published
Drag-to-reorder for lists in any framework or none: rows dragged by a handle with touch, mouse, pen or the keyboard, shifted on screen while dragging, and handed back as a move for the app to apply.
Maintainers
Readme
@bro-code/drag-it
Drag-to-reorder for lists in any framework or none: rows dragged by a handle with touch, mouse, pen or the keyboard, shifted on screen while dragging, and handed back as a move for the app to apply.
A row is picked up by its handle and follows the pointer, and the rows it passes slide aside to make room. Lists in the same group trade rows, and the keyboard does everything the pointer does, each step spoken to a screen reader. Everything on screen moves by CSS transforms: the package never moves, adds or removes an element, since a list drawn by Vue, React or any other library belongs to that library. When the drag ends somewhere new, onMove says where the row came from and where it went, and the app reorders its own data, which redraws the list. Rows are read when a drag starts, so a list redrawn between drags just works.
It has no runtime dependencies and does nothing when imported. Without a window (server rendering), createDragIt does nothing and returns a destroy that does nothing.
Installation
npm install @bro-code/drag-itUsage
<ul id="chores">
<li><button class="grip" aria-label="Move Mow the lawn">⋮</button> Mow the lawn</li>
<li><button class="grip" aria-label="Move Take out the trash">⋮</button> Take out the trash</li>
</ul>import { createDragIt } from '@bro-code/drag-it';
const chores = ['Mow the lawn', 'Take out the trash'];
const list = document.getElementById('chores')!;
const dragIt = createDragIt(list, {
handle: '.grip',
onMove: ({ from, to }) => {
const [chore] = chores.splice(from.index, 1);
chores.splice(to.index, 0, chore);
render(); // draw the list again from chores
},
});
// When the list goes away
dragIt.destroy();With CommonJS:
const { createDragIt } = require('@bro-code/drag-it');Vue
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { createDragIt, type DragIt } from '@bro-code/drag-it';
const chores = ref([{ id: 1, text: 'Mow the lawn' }, { id: 2, text: 'Take out the trash' }]);
const list = ref<HTMLElement>();
let dragIt: DragIt | undefined;
onMounted(() => {
dragIt = createDragIt(list.value!, {
handle: '.grip',
onMove: ({ from, to }) => {
const [chore] = chores.value.splice(from.index, 1);
chores.value.splice(to.index, 0, chore);
},
});
});
onBeforeUnmount(() => dragIt?.destroy());
</script>
<template>
<ul ref="list">
<li v-for="chore in chores" :key="chore.id">
<button class="grip" :aria-label="`Move ${chore.text}`">⋮</button> {{ chore.text }}
</li>
</ul>
</template>React
import { useEffect, useRef, useState } from 'react';
import { createDragIt } from '@bro-code/drag-it';
export function Chores() {
const [chores, setChores] = useState([{ id: 1, text: 'Mow the lawn' }, { id: 2, text: 'Take out the trash' }]);
const list = useRef<HTMLUListElement>(null);
useEffect(() => {
const dragIt = createDragIt(list.current!, {
handle: '.grip',
onMove: ({ from, to }) =>
setChores((current) => {
const next = [...current];
const [chore] = next.splice(from.index, 1);
next.splice(to.index, 0, chore);
return next;
}),
});
return () => dragIt.destroy();
}, []);
return (
<ul ref={list}>
{chores.map((chore) => (
<li key={chore.id}>
<button className="grip" aria-label={`Move ${chore.text}`}>⋮</button> {chore.text}
</li>
))}
</ul>
);
}Give each row a stable key (:key, key), so the library keeps each row's element when the order changes.
Options
| Option | Type | |
|---|---|---|
| handle | string | A selector for the handle inside a row. A drag starts only from a handle, by the primary button, a finger or a pen. |
| items | string | A selector for the rows inside the list. Default: the list's element children. |
| group | string | Lists made with the same group trade rows. Default: none, so a list's rows stay in it. |
| announcements | Partial<DragItAnnouncements> | The words a screen reader hears, in place of the English defaults (below). |
| onMove | (move: DragItMove) => void | Called when a drag ends somewhere other than where it began, with { from: { list, index }, to: { list, index } }. The indexes count the rows as they were when the drag started. A row that went to another list is reported by the list it came from. |
The list is vertical. While a row is dragged near the top or bottom edge of whatever scrolls the list (its nearest scrolling ancestor, or the page), that scrolls too, faster nearer the edge. Escape, or the pointer being cancelled, puts the row back with no move.
Groups
Give several lists the same group and a row dragged out of one can be dropped into any of them: the list under the pointer opens a gap where the row would land, and the list it left closes up. While it is between lists, it stays aimed at the last list it was over. An empty list takes a drop anywhere in its box, so give it a height (a min-height). The row follows the pointer sideways too, so give it a z-index above the other lists.
const days = document.querySelectorAll<HTMLElement>('.day-list');
for (const list of days) {
createDragIt(list, {
handle: '.grip',
group: 'week',
onMove: ({ from, to }) => moveChore(from.list.dataset.day!, from.index, to.list.dataset.day!, to.index),
});
}Accessibility
The handle is the app's own element; make it focusable (a <button>, or tabindex="0") and name what it moves (aria-label="Move Mow the lawn"). On a focused handle:
| Key | | |---|---| | Space or Enter | Picks the row up, and drops it again. | | ArrowUp, ArrowDown | Moves it one place. Past either end it goes into the previous or next list of its group, in document order. | | Escape | Puts it back where it was. Moving the focus away does too. |
Each step is spoken through one visually hidden aria-live="assertive" region, added to document.body the first time something is said and removed when the last instance is destroyed. A pointer drag is spoken the same way. The words come from announcements, each a function of { row, from, to, count } (count is how many places the list the row is in now has, counting the row); the defaults are:
| Step | Default |
|---|---|
| pickedUp | Picked up, position 2 of 5. |
| moved | Moved to position 3 of 5. (… in another list. when it crossed into one) |
| dropped | Dropped at position 3 of 5. |
| cancelled | Cancelled. Back at position 2 of 5. |
createDragIt(list, {
handle: '.grip',
announcements: {
pickedUp: ({ row, from, count }) => `${row.dataset.name} lifted, ${from.index + 1} of ${count}.`,
},
onMove,
});Styling
The package sets only transform on the rows while a drag is on, touch-action: none on the handles (so a finger on one drags instead of scrolling), and two classes for the app to style:
| Class | On | |
|---|---|---|
| drag-it-dragging | the row being dragged | Lift it: a shadow, a background, position: relative and a z-index so it passes over the rows. |
| drag-it-active | the list, and every list of its group, while a drag is on | Animate the rows sliding aside, for example .drag-it-active > li:not(.drag-it-dragging) { transition: transform 150ms; }. |
.drag-it-dragging {
position: relative;
z-index: 1;
background: white;
box-shadow: 0 4px 12px rgb(0 0 0 / 0.2);
}
.drag-it-active > li:not(.drag-it-dragging) {
transition: transform 150ms ease;
}
.grip {
cursor: grab;
}API
| Export | |
|---|---|
| createDragIt(list, options) | Makes the rows of list draggable by their handles and returns { destroy() }, which removes every listener and puts back a row being dragged. |
| Types | DragIt, DragItOptions, DragItMove, DragItPosition, DragItAnnouncements, DragItAnnouncement. |
License
MIT - see LICENSE.
Changelog
[2026.0.0] - 2026-11-13
Added
- Initial release.
createDragIt(list, options), withdestroy(): rows dragged by a handle with touch, mouse or pen, shifted by CSS transforms, and handed back toonMoveas{ from, to }.- The
handleanditemsoptions, Escape and a cancelled pointer putting the row back, and scrolling at the edges of whatever scrolls the list. group: lists that trade rows, an empty one taking a drop anywhere in its box.- The keyboard on a focused handle: Space or Enter to pick up and drop, the arrows to move (into the next or previous list of the group past either end), Escape or leaving the handle to put the row back.
- Announcements through one
aria-liveregion, in theannouncementsoption's words or English defaults. - The
drag-it-dragginganddrag-it-activeclasses.
