An accessibility-first drag-and-drop utility for Livewire and Alpine.js. Reorder lists, move items between connected containers (kanban boards), and let mouse, touch, pen, and keyboard users all do the same things — with translatable screen reader announcements and a lifecycle you can veto and roll back.
- Connected containers — contexts that share a group accept each other's items, including when empty.
- Mouse, touch, and pen — pointer-event based, so it works on tablets (press and hold to pick up on touch).
- Keyboard — grab with Space/Enter, move within a list with Up/Down/Home/End, move between containers with Left/Right, drop with Space/Enter, cancel with Escape.
- Screen readers — instructions via
aria-describedby, every step announced in a live region, every message a translatable template. - Lifecycle events —
drag:start,drag:over, a cancelabledrag:drop,drag:end,drag:cancel, anddrag:revert. - Optimistic moves — every move has a
moveId; revert it after a server rejection. - Livewire 3 and 4 — survives morphing; focus stays on the moved item.
- Nested contexts — reorder columns while cards move between them, without events crossing.
- Auto-scroll — scrolling parents (and the page) scroll while you drag near their edges.
npm install @artisanpack-ui/livewire-drag-and-dropImport the package in your app bundle. It registers itself with Livewire's bundled Alpine on alpine:init and hooks into Livewire on livewire:init:
// resources/js/app.js
import '@artisanpack-ui/livewire-drag-and-drop';import Alpine from 'alpinejs';
import LivewireDragAndDrop from '@artisanpack-ui/livewire-drag-and-drop';
Alpine.plugin(LivewireDragAndDrop);
Alpine.start();Load the UMD build before Alpine starts:
<script src="https://unpkg.com/@artisanpack-ui/livewire-drag-and-drop@2/dist/livewire-drag-and-drop.umd.js"></script>
<script defer src="https://unpkg.com/alpinejs@3/dist/cdn.min.js"></script>The package is then available as window.LivewireDragAndDrop.
<ul
x-drag-context
data-drag-label="{{ __( 'Tasks' ) }}"
@drag:end="$wire.reorder( $event.detail.orderedIds )"
>
@foreach ( $tasks as $task )
<li x-drag-item="{{ $task->id }}" wire:key="task-{{ $task->id }}" data-drag-label="{{ $task->title }}">
{{ $task->title }}
</li>
@endforeach
</ul>public function reorder( array $orderedIds ): void
{
foreach ( $orderedIds as $position => $id ) {
Task::whereKey( $id )->update( [ 'position' => $position ] );
}
}$event.detail.orderedIds is the context's item ids in their new order.
The package adds no visual styles of its own except the floating copy of the dragged item and the drop indicator. Style the states with attribute selectors:
/* The item being dragged (stays in place, dimmed, while a copy follows the pointer). */
[x-drag-item][data-dragging] { opacity: 0.4; }
/* Contexts that can receive the dragged item / that refuse it right now. */
[data-drag-active] { outline: 2px dashed #93c5fd; }
[data-drag-disallowed] { opacity: 0.5; cursor: not-allowed; }
/* The context currently under the pointer (or the keyboard target). */
[data-drag-over] { background: #eff6ff; }
/* The insertion line. Its color comes from a custom property. */
:root { --drag-indicator-color: #2563eb; }
/* Give empty contexts a drop area. */
[x-drag-context] { min-height: 3rem; }body also gets data-drag-in-progress="pointer" or "keyboard" while a drag is active.
Give contexts the same group to let items move between them. Each context needs an id so events can say where an item came from and went to:
<div class="grid grid-cols-2 gap-4">
<ul x-drag-context data-drag-group="tasks" data-drag-context-id="todo" data-drag-label="{{ __( 'To do' ) }}"
@drag:end="$event.detail.toElement === $el && $wire.move( $event.detail )">
@foreach ( $todo as $task )
<li x-drag-item="{{ $task->id }}" wire:key="task-{{ $task->id }}" data-drag-label="{{ $task->title }}">{{ $task->title }}</li>
@endforeach
</ul>
<ul x-drag-context data-drag-group="tasks" data-drag-context-id="done" data-drag-label="{{ __( 'Done' ) }}"
@drag:end="$event.detail.toElement === $el && $wire.move( $event.detail )">
@foreach ( $done as $task )
<li x-drag-item="{{ $task->id }}" wire:key="task-{{ $task->id }}" data-drag-label="{{ $task->title }}">{{ $task->title }}</li>
@endforeach
</ul>
</div>For a move between contexts, drag:end is dispatched on both contexts. The toElement === $el check makes only the receiving context call the server; the detail has everything you need:
public function move( array $detail ): void
{
$task = Task::findOrFail( $detail['itemId'] );
$task->update( [ 'status' => $detail['toContext'] ] );
foreach ( $detail['toOrderedIds'] as $position => $id ) {
Task::whereKey( $id )->update( [ 'position' => $position ] );
}
}The group can also be set with a modifier: x-drag-context.group.tasks. Attribute names are lowercase in HTML, so modifier group names are lowercase too.
Columns are items of an outer context (so they can be reordered) and contexts of their own (so cards can move between them). Events never cross: moving a card dispatches nothing on the board, and moving a column dispatches nothing on the card lists.
<div
x-drag-context
data-drag-group="columns"
data-drag-context-id="board"
data-drag-label="{{ __( 'Board' ) }}"
data-drag-orientation="horizontal"
class="flex gap-4 overflow-x-auto"
@drag:end="$wire.reorderColumns( $event.detail.orderedIds )"
>
@foreach ( $columns as $column )
<section
x-drag-item="'{{ $column->id }}'"
wire:key="column-{{ $column->id }}"
data-drag-label="{{ $column->name }}"
class="w-72 shrink-0"
>
<h3>{{ $column->name }}</h3>
<ul
x-drag-context="{ disabled: {{ $column->isFull() ? 'true' : 'false' }} }"
data-drag-group="cards"
data-drag-context-id="{{ $column->id }}"
data-drag-label="{{ $column->name }}"
data-drag-messages='@json( [ 'rejected' => __( ':column is full.', [ 'column' => $column->name ] ) ] )'
wire:key="column-{{ $column->id }}-cards"
class="min-h-24"
@drag:end="$event.detail.toElement === $el && $wire.moveCard( $event.detail ).then( ( ok ) => ok || $dragRevert( $event.detail.itemId ) )"
>
@foreach ( $column->cards as $card )
<li x-drag-item="{{ $card->id }}" wire:key="card-{{ $card->id }}" data-drag-label="{{ $card->title }}">
{{ $card->title }}
</li>
@endforeach
</ul>
</section>
@endforeach
</div>Notes:
- Put
wire:keyon items and contexts so Livewire morphs move the right elements. - When a context's items live in a child element (a column with a header and a list), put
x-drag-contexton the list, or mark the list withdata-drag-itemsso drops into the empty column land in it. - A column cannot be dropped into its own card list; dragging a column over cards targets the board.
A context refuses drops while it is disabled or while its accepts callback returns false. It gets data-drag-disallowed during the drag, no drop indicator is shown, and a drop there is cancelled and announced (rejected message).
{{-- Static --}}
<ul x-drag-context data-drag-disabled>…</ul>
{{-- Reactive (Alpine expression) --}}
<ul x-drag-context="{ disabled: cards.length >= limit }">…</ul>
{{-- Per item --}}
<ul x-drag-context="{ accepts: ( detail ) => detail.fromContext !== 'archived' }">…</ul>Disabled contexts still let their own items be dragged out.
To decide at drop time instead, cancel drag:drop. It is dispatched before the DOM changes; cancelling it leaves the item where it was and announces your message:
<ul x-drag-context data-drag-group="cards"
@drag:drop="if ( atLimit ) { $event.preventDefault(); $event.detail.message = @js( __( 'This column is at its WIP limit.' ) ) }">$event.detail.reject( message ) does the same in one call.
The DOM is updated as soon as the item is dropped. If the server then rejects the move, put it back:
<ul x-drag-context data-drag-group="cards" data-drag-context-id="{{ $status }}"
@drag:end="
if ( $event.detail.toElement !== $el ) return;
const { moveId } = $event.detail;
$wire.moveCard( $event.detail )
.then( ( ok ) => ok || LivewireDragAndDrop.revert( moveId ) )
.catch( () => LivewireDragAndDrop.revert( moveId ) );
">use Livewire\Attributes\Renderless;
#[Renderless]
public function moveCard( array $detail ): bool
{
$card = Card::findOrFail( $detail['itemId'] );
if ( ! $card->canTransitionTo( $detail['toContext'] ) ) {
return false;
}
$card->moveTo( $detail['toContext'], $detail['toIndex'] );
return true;
}LivewireDragAndDrop.revert( moveId )reverts that move;$dragRevert( itemId )(Alpine magic) reverts the item's latest move. Both returntrueif anything moved back.- The
moveIdkeeps several in-flight moves apart: reverting one never touches another item. - Reverting a move also discards later recorded moves of the same item.
- A revert dispatches
drag:reverton the contexts involved and announces it. - If your action re-renders instead (no
#[Renderless]), Livewire's morph restores the server's order itself; you don't need to revert.
| Key | When not holding | While holding an item |
|---|---|---|
| Tab | Moves between items | Cancels the move (focus leaving the item cancels it) |
| Space / Enter | Picks the item up | Drops it |
| Up / Down | — | Moves it within its container |
| Home / End | — | Moves it to the start / end of its container |
| Left / Right | — | Moves it to the previous / next container in its group (skipping containers that refuse it) |
| Escape | — | Cancels: back to the original container and position |
- In a horizontal context (
data-drag-orientation="horizontal", or adisplay: flexrow), Left/Right move within the list and Up/Down move between containers. - A context with no other containers in its group uses the between-container keys to move within the list, so Left/Right keep working on ungrouped lists.
- Keys are only handled when the item itself has focus, so buttons and inputs inside items keep working.
- After a keyboard drop, focus stays on the item — also after a Livewire morph replaces it.
Rebind keys globally (values of KeyboardEvent.key):
LivewireDragAndDrop.configure({
keys: { previousContainer: ['ArrowLeft', 'h'], nextContainer: ['ArrowRight', 'l'] },
});What the package sets (author-provided values are never overwritten):
- Contexts:
role="group"andaria-labelfromdata-drag-label. - Items:
tabindex="0",role="button", andaria-describedbypointing at hidden usage instructions. - No
aria-grabbed(deprecated in ARIA 1.1). The held state is conveyed through the announcements.
Every announcement is a template with {item}, {position}, {total}, and {container} placeholders. {item} and {container} come from data-drag-label on the item and context.
| Key | Default |
|---|---|
instructions |
Press Space or Enter to pick up. While holding, use the arrow keys to move, Home or End to jump to the start or end, Space or Enter to drop, and Escape to cancel. |
grabbed |
{item} grabbed. Position {position} of {total} in {container}. |
moved |
{item} moved to position {position} of {total} in {container}. |
movedToContainer |
{item} moved to {container}, position {position} of {total}. |
dropped |
{item} dropped in {container}, position {position} of {total}. |
cancelled |
Move cancelled. {item} returned to {container}, position {position} of {total}. |
rejected |
{item} cannot be dropped in {container}. |
reverted |
{item} moved back to {container}, position {position} of {total}. |
noContainer |
There is no other container in that direction. |
item |
Item (label used when an item has no data-drag-label) |
container |
the list (label used when a context has no data-drag-label) |
Set them globally — typically once in your layout so Blade can translate them:
<script>
document.addEventListener( 'alpine:init', () => {
LivewireDragAndDrop.configure( {
messages: {
instructions: @js( __( 'Press Space or Enter to pick up…' ) ),
grabbed: @js( __( '{item} grabbed. Position {position} of {total} in {container}.' ) ),
movedToContainer: @js( __( '{item} moved to {container}, position {position} of {total}.' ) ),
dropped: @js( __( '{item} dropped in {container}, position {position} of {total}.' ) ),
},
} );
} );
</script>…or per context with JSON (merged over the global messages):
<ul x-drag-context data-drag-messages='@json( [ 'grabbed' => __( '{item} grabbed.' ) ] )'>Use {placeholder} braces (not Laravel's :placeholder) so the package can fill them in on the client.
Marks a container whose x-drag-item descendants can be reordered (items of nested contexts belong to the nested context).
| Attribute / option | Description |
|---|---|
data-drag-context-id / { id } |
Id reported as fromContext / toContext. Falls back to the element id. |
data-drag-group / { group } / .group.name |
Contexts with the same group accept each other's items. |
data-drag-label / { label } |
Accessible name and {container} in announcements. |
data-drag-disabled / { disabled } |
Accept nothing right now (data-drag-disabled="false" means enabled). |
{ accepts: (detail) => boolean } |
Accept rule, called with { moveId, itemId, group, fromContext, fromIndex, toContext }. |
data-drag-messages / { messages } |
Announcement templates for this context. |
data-drag-orientation / { orientation } |
vertical or horizontal. Defaults to horizontal for a flex row, else vertical. |
data-drag-items (on a child) |
Where items are inserted when the context is empty. |
The expression is optional and is an Alpine object (x-drag-context="{ disabled: full }"); it is re-evaluated on every check, so it is reactive. Expression values win over attributes.
x-drag-item="expression" — the expression is the item's id ({{ $item->id }}, '{{ $uuid }}', item.id). Add data-drag-label for announcements. Dragging doesn't start from inputs, textareas, selects, contenteditable regions, or elements with data-drag-ignore.
Events are dispatched on the context elements and do not bubble, so listen on the context (@drag:end on the x-drag-context element). Details are plain JSON-safe objects you can pass straight to $wire; itemElement, fromElement, and toElement are also available but not serialized.
| Event | Dispatched on | Detail |
|---|---|---|
drag:start |
source | { moveId, itemId, group, fromContext, fromIndex }. Cancelable: prevents the drag. |
drag:over |
target | … , toContext, toIndex, allowed. Fires when the target or insertion index changes. |
drag:drop |
target, then source | … , toContext, toIndex. Cancelable, before the DOM changes. Set detail.message or call detail.reject(message). |
drag:end |
target and source | { moveId, itemId, group, fromContext, toContext, fromIndex, toIndex, orderedIds, fromOrderedIds, toOrderedIds }. orderedIds is the order of the context the event is dispatched on. |
drag:cancel |
source | { moveId, itemId, group, fromContext, fromIndex, reason }, reason is escape, blur, outside, not-allowed, rejected, pointercancel, or removed. |
drag:revert |
original source and target | Same shape as drag:end; fromContext/toContext describe the move being undone. |
Indices are zero-based positions among the context's items.
drag:endnow has the full detail above (2.0 only hadorderedIds, which is unchanged for same-list reorders).- Events no longer bubble. Listen on the
x-drag-contextelement rather than an ancestor,document, or Livewire#[On]. - Native HTML5 drag-and-drop was replaced with pointer events; items no longer get
draggable="true". aria-grabbedis no longer set; contexts getrole="group"(2.0 set no role).- Arrow keys only move between containers for grouped contexts; ungrouped lists behave as before.
- The 2.0 Livewire hook (
message.processed) doesn't exist in Livewire 3/4; 2.1 usesmorphedand no longer marks moved items__livewire_ignore, so server re-renders apply to them again.
Current Chrome, Edge, Firefox, Safari (macOS and iOS), and Chrome for Android. Requires Pointer Events and Alpine.js 3.
npm install
npm test # jsdom unit/integration tests (Jest + real Alpine)
npm run test:browser # keyboard and mouse tests in headless Chrome (Puppeteer)
npm run build # dist/ (ES module + UMD)See CONTRIBUTING.md and the changelog. Full guides — connected containers, events, configuration, styling, accessibility, and upgrading — are in docs/.
MIT — see LICENSE.
Created by Jacob Martella for the ArtisanPack UI ecosystem. Built with Alpine.js and Livewire.
