drag-surface
Accessible drag-and-drop reorder surface with keyboard support, live region announcements, and cross-surface transfer.
Overview
The <drag-surface> component wraps the native HTML Drag and Drop API, adding keyboard accessibility, live region announcements for screen readers, and clean custom events. It supports both single-surface reorder and cross-surface transfer between surfaces that share a group attribute.
Children just need draggable="true" — the component handles all event wiring, state attributes for CSS, and ARIA management.
<drag-surface data-layout="stack" data-layout-gap="xs"> <article class="card" draggable="true" data-id="task-1">Design review</article> <article class="card" draggable="true" data-id="task-2">Code audit</article> <article class="card" draggable="true" data-id="task-3">Deploy staging</article></drag-surface>
<drag-surface> uses flex: 1 to fill available space when placed inside a flex container (such as a kanban column using data-layout="stack"). This ensures the full column area is a valid drop target, not just the space occupied by cards.
Attributes
On <drag-surface>
| Attribute | Type | Default | Description |
|---|---|---|---|
group |
string | — | Transfer group. Items can move between surfaces that share the same group value. |
orientation |
string | "vertical" |
Set to "horizontal" to use Left/Right arrows and clientX for drop position. |
disabled |
boolean | — | Disables all dragging on this surface. |
On Children
| Attribute | Type | Description |
|---|---|---|
draggable="true" |
boolean | Native HTML — marks the element as draggable. Required. |
data-id |
string | Stable identifier for the item (survives reorder). |
data-sort-order |
number | Numeric position within the surface. Managed automatically on reorder. |
data-drag-handle |
boolean | If present on a descendant, only that element initiates the drag. |
CSS State Attributes
The component manages visual state entirely through data attributes, keeping all styling in CSS.
| Attribute | Set On | When |
|---|---|---|
data-dragging |
The dragged child | During a mouse drag operation |
data-drag-over |
The surface | A dragged item is hovering over a valid drop target |
data-drop-target="before|after" |
A child item | Shows where the dragged item will be inserted |
data-reorder-mode |
The surface | During keyboard reorder (item is grabbed) |
aria-grabbed="true" |
A child item | Item is grabbed for keyboard reorder |
data-just-dropped |
A child item | Briefly set after a drop (mouse or keyboard) to trigger a flash animation. Removed automatically after the animation ends. |
Kanban Board
Link multiple surfaces with group to enable cross-surface transfer. Items can be dragged between any surfaces that share the same group value.
<section data-layout="grid" data-layout-min="15rem"> <section data-layout="stack" data-layout-gap="s"> <h3>To Do</h3> <drag-surface group="tasks" aria-label="To Do" data-layout="stack" data-layout-gap="xs"> <article class="card" draggable="true" data-id="task-1">Design review</article> <article class="card" draggable="true" data-id="task-2">Write docs</article> </drag-surface> </section> <section data-layout="stack" data-layout-gap="s"> <h3>In Progress</h3> <drag-surface group="tasks" aria-label="In Progress" data-layout="stack" data-layout-gap="xs"> <article class="card" draggable="true" data-id="task-3">API integration</article> </drag-surface> </section> <section data-layout="stack" data-layout-gap="s"> <h3>Done</h3> <drag-surface group="tasks" aria-label="Done" data-layout="stack" data-layout-gap="xs"> <!-- empty, ready to receive --> </drag-surface> </section></section>
Drag Handles
Add data-drag-handle to a descendant element to constrain where the drag can be initiated. Only clicks on the handle start a drag — clicking other parts of the item does not.
<drag-surface data-layout="stack" data-layout-gap="xs"> <article class="card" draggable="true" data-id="item-1"> <span data-drag-handle>☰</span> Item with drag handle </article> <article class="card" draggable="true" data-id="item-2"> <span data-drag-handle>☰</span> Another item </article></drag-surface>
Horizontal Layout
Set orientation="horizontal" to switch keyboard navigation to Left/Right arrows and use horizontal position for drop calculations.
<drag-surface orientation="horizontal" data-layout="cluster"> <article class="card" draggable="true" data-id="col-a">Column A</article> <article class="card" draggable="true" data-id="col-b">Column B</article> <article class="card" draggable="true" data-id="col-c">Column C</article></drag-surface>
Events
| Event | Detail | Description |
|---|---|---|
drag-surface:reorder |
{item, itemId, oldIndex, newIndex, order} |
Items within this surface were reordered (mouse or keyboard). |
drag-surface:transfer |
{item, itemId, fromSurface, toSurface, newIndex, fromOrder, toOrder} |
An item moved between surfaces sharing a group. |
drag-surface:reorder-start |
— | A drag or keyboard reorder began. |
drag-surface:reorder-end |
— | A drag or keyboard reorder ended. |
const surface = document.querySelector('drag-surface'); surface.addEventListener('drag-surface:reorder', (e) => { console.log(e.detail); // { // item: HTMLElement, // itemId: "task-2", // oldIndex: 2, // newIndex: 0, // order: ["task-2", "task-1", "task-3"] // }}); // Cross-surface transfer (listen on document or a parent)document.addEventListener('drag-surface:transfer', (e) => { console.log(e.detail); // { // item: HTMLElement, // itemId: "task-1", // fromSurface: HTMLElement, // toSurface: HTMLElement, // newIndex: 1, // fromOrder: ["task-2"], // toOrder: ["task-3", "task-1"] // }});
Keyboard Navigation
| Key | Action |
|---|---|
| Space / Enter | Grab the focused item (toggle). Press again to drop at current position. |
| ArrowUp / ArrowDown | Move the grabbed item up/down (vertical orientation). |
| ArrowLeft / ArrowRight | Move the grabbed item left/right (horizontal orientation), or transfer between grouped surfaces (vertical orientation). |
| Escape | Cancel the reorder and return the item to its original position. Does not undo cross-surface transfers. |
Keyboard Transfer
When surfaces share a group, grabbed items can move between them using the perpendicular arrow keys:
- Vertical surfaces (default): ArrowLeft / ArrowRight transfer to the adjacent surface
- Horizontal surfaces: ArrowUp / ArrowDown transfer to the adjacent surface
Adjacent surfaces are determined by visual position (left-to-right, top-to-bottom). The transfer is committed immediately — pressing Escape afterwards will not undo a cross-surface transfer, only cancel further reordering within the new surface.
After transfer, the item receives focus in the new surface and a data-just-dropped flash animation plays. The drag-surface:transfer event fires on the receiving surface.
Disabled State
Add disabled to the surface to temporarily prevent all dragging.
<!-- Disable the entire surface --><drag-surface disabled data-layout="stack" data-layout-gap="xs"> <article class="card" draggable="true" data-id="a">Can't drag me</article> <article class="card" draggable="true" data-id="b">Or me</article></drag-surface>
Accessibility
ARIA Roles
The surface sets role="list" on itself and ensures each draggable child has role="listitem", tabindex="0", and aria-grabbed="false".
Live Region
A visually hidden aria-live="polite" region announces position changes during keyboard reorder (e.g., “Position 2 of 5”) and grab/drop actions.
Keyboard Alternative
The keyboard reorder mode is the accessible alternative to mouse drag-and-drop. Users can grab items with Space/Enter, move with arrow keys, drop with Space/Enter, and cancel with Escape.
Reduced Motion
When prefers-reduced-motion: reduce is active, the component skips any transition animations.
Progressive Enhancement
| Layer | What Works | What’s Missing |
|---|---|---|
| HTML only | Content is readable as a static list | No interactivity |
HTML + draggable |
Browser provides native drag ghost | No drop handling, no keyboard support |
HTML + <drag-surface> JS |
Full reorder, transfer, keyboard, live announcements | No persistence (resets on reload) |
| HTML + JS + consumer code | Consumer listens to events, persists order | Full experience |
Related
draggable— the native HTML attribute that makes elements draggable<slide-accept>— slide-to-confirm interaction