Drag & Drop
What's new in 1.14.0
DnD gained programmatic handle APIs, grid / delay / touchStartThreshold / keyboard / keyboardStep options, 'viewport' bounds, and the reactive useDraggable / useDroppable / useSortable composables in 1.14.0. See the 1.14.0 release notes.
The drag-and-drop module adds pointer-based dragging, droppable zones, and sortable lists with touch support and configurable constraints.
import { draggable, droppable, sortable } from '@bquery/bquery/dnd';Stability
dnd has been Beta, and its most important capabilities are new: keyboard accessibility, grid mode, delay, 'viewport' bounds, and the reactive useDraggable / useDroppable / useSortable composables all arrived in 1.14.0. Accessible DnD specifically needs rigorous validation, so a feature set one minor old has not yet proven itself. The work to graduate it is tracked in #143: freeze the surface for one minor cycle, harden the keyboard interaction model, and publish an accessibility statement. It graduated to Stable in 1.15.0, with the surface frozen under the no-breaking-changes-between-minors contract.
Exit criteria
- [x] Public surface frozen for one minor — see Frozen surface reference below; no breaking changes land during the freeze.
- [x] Keyboard + pointer paths tested (#143) — keyboard pick up / move / drop / cancel,
aria-grabbedtransitions, and thegrid/delay/viewportoptions are covered. - [x] Accessibility behaviour documented — see Accessibility statement below.
- [x] Surface frozen (no breaking changes) — committed under the Stable contract from 1.15.0.
Frozen surface reference (1.15.0)
The frozen public surface of @bquery/bquery/dnd:
- Imperative:
draggable,droppable,sortable. - Reactive composables:
useDraggable,useDroppable,useSortable, plusdraggablePosition,sortableOrder. draggableoptions (frozen):axis,bounds('parent'/'viewport'/ selector / element / rect),grid,delay,ghost,handle,disabled,touchStartThreshold,keyboard,keyboardStep, and theonDragStart/onDrag/onDragEndcallbacks.
Accessibility statement
First-party, keyboard-accessible drag-and-drop is the differentiator here, so the keyboard path is a first-class, documented contract — not an afterthought.
Keyboard model (enable with keyboard: true):
| Key | Action |
|---|---|
Space / Enter | Pick up the item (or drop it when already lifted) |
Arrow keys | Move by keyboardStep pixels (default 10) |
Escape | Cancel — return the item to its original position |
ARIA & announcements:
- The draggable element exposes
aria-grabbed('false'at rest,'true'while lifted) so assistive technology can convey grab state. - Pick up, drop, and cancel each emit a screen-reader announcement through the shared
a11ylive-region announcer (announceToScreenReader) — the same mechanism documented in the A11y guide (#142). DnD does not create a second announcement channel. - Apply a visible focus style to draggable/handle elements and give them a tab stop (
tabindex="0"); keyboard dragging starts from the focused element.
Known limitations:
- Pointer dragging reports positions via
getBoundingClientRect, which is unavailable in non-rendering environments (e.g. SSR / headless tests) — keyboard moves and callbacks still fire, but pixel geometry is not meaningful there. - Announcement text is currently English; wrap or post-process via your own
a11y/i18nlayer to localize.
draggable()
Makes an element draggable using pointer events with optional axis locking, bounds constraints, drag handles, and ghost previews.
Signature
function draggable(el: HTMLElement, options?: DraggableOptions): DraggableHandle;Parameters
| Parameter | Type | Description |
|---|---|---|
el | HTMLElement | The element to make draggable |
options | DraggableOptions | Optional configuration (see below) |
DraggableOptions
interface DraggableOptions {
/** Restrict drag to a single axis. Default: `'both'` */
axis?: DragAxis;
/** Restrict drag within bounds. Accepts `'parent'`, a CSS selector, or a `BoundsRect`. */
bounds?: DragBounds;
/** CSS selector for a child drag handle. If set, only this child starts a drag. */
handle?: string;
/** Display a ghost clone during drag. Default: `false` */
ghost?: boolean;
/** CSS class applied to the ghost clone. Default: `'bq-drag-ghost'` */
ghostClass?: string;
/** CSS class applied to the element during drag. Default: `'bq-dragging'` */
draggingClass?: string;
/** Disable dragging without destroying the handle. Default: `false` */
disabled?: boolean;
/** Called when dragging begins. */
onDragStart?: (data: DragEventData) => void;
/** Called on every pointer move during drag. */
onDrag?: (data: DragEventData) => void;
/** Called when the drag ends (pointer released). */
onDragEnd?: (data: DragEventData) => void;
}Return Value: DraggableHandle
interface DraggableHandle {
/** Remove all listeners and stop all drag behavior. */
destroy: () => void;
/** Temporarily disable dragging. */
disable: () => void;
/** Re-enable dragging after `disable()`. */
enable: () => void;
/** Whether the draggable is currently enabled. */
readonly enabled: boolean;
}Examples
Basic drag:
const card = document.querySelector('#card')!;
const drag = draggable(card);
// Later: clean up
drag.destroy();Constrained to parent with ghost:
const drag = draggable(document.querySelector('#card')!, {
axis: 'both',
bounds: 'parent',
ghost: true,
onDragEnd: ({ position }) => {
console.log(`Dropped at (${position.x}, ${position.y})`);
},
});Horizontal-only with handle:
const drag = draggable(document.querySelector('#slider')!, {
axis: 'x',
handle: '.drag-thumb',
draggingClass: 'sliding',
});Custom bounds rectangle:
const drag = draggable(document.querySelector('#box')!, {
bounds: { left: 0, top: 0, right: 800, bottom: 600 },
});Toggle dragging on/off:
const drag = draggable(document.querySelector('#card')!);
drag.disable();
console.log(drag.enabled); // false
drag.enable();
console.log(drag.enabled); // truedroppable()
Defines a drop zone that reacts to dragged elements entering, hovering over, leaving, and being dropped onto it.
Signature
function droppable(el: HTMLElement, options?: DroppableOptions): DroppableHandle;Parameters
| Parameter | Type | Description |
|---|---|---|
el | HTMLElement | The drop zone element |
options | DroppableOptions | Optional configuration (see below) |
DroppableOptions
interface DroppableOptions {
/** CSS class applied when a draggable hovers over the zone. Default: `'bq-drop-over'` */
overClass?: string;
/** Accept filter — CSS selector or function. Only matching elements trigger drop events. */
accept?: string | ((el: HTMLElement) => boolean);
/** Called when a draggable enters the zone. */
onDragEnter?: (data: DropEventData) => void;
/** Called repeatedly while a draggable hovers over the zone. */
onDragOver?: (data: DropEventData) => void;
/** Called when a draggable leaves the zone. */
onDragLeave?: (data: DropEventData) => void;
/** Called when a draggable is dropped in the zone. */
onDrop?: (data: DropEventData) => void;
}Return Value: DroppableHandle
interface DroppableHandle {
/** Remove all listeners and clean up. */
destroy: () => void;
}Examples
Basic drop zone:
const drop = droppable(document.querySelector('#drop-zone')!, {
onDrop: ({ dragged, zone }) => {
console.log('Dropped', dragged, 'into', zone);
},
});
drop.destroy();Filtered by CSS selector:
const drop = droppable(document.querySelector('#trash')!, {
accept: '.deletable',
overClass: 'trash-hover',
onDrop: ({ dragged }) => {
dragged.remove();
},
});Filtered by function:
const drop = droppable(document.querySelector('#drop-zone')!, {
accept: (el) => el.dataset.type === 'image',
onDragEnter: ({ zone }) => zone.classList.add('highlight'),
onDragLeave: ({ zone }) => zone.classList.remove('highlight'),
onDrop: ({ dragged, zone }) => {
zone.appendChild(dragged);
},
});sortable()
Makes the children of a container sortable by dragging, with animated reordering.
Signature
function sortable(container: HTMLElement, options?: SortableOptions): SortableHandle;Parameters
| Parameter | Type | Description |
|---|---|---|
container | HTMLElement | The container whose children become sortable |
options | SortableOptions | Optional configuration (see below) |
SortableOptions
interface SortableOptions {
/** CSS selector for sortable items. Default: `'> *'` (direct children) */
items?: string;
/** Sort axis — `'x'` for horizontal, `'y'` for vertical. Default: `'y'` */
axis?: 'x' | 'y';
/** CSS selector for a drag handle within each item. */
handle?: string;
/** CSS class applied to the placeholder element. Default: `'bq-sort-placeholder'` */
placeholderClass?: string;
/** CSS class applied to the item being sorted. Default: `'bq-sorting'` */
sortingClass?: string;
/** Animation duration in milliseconds. Default: `200` */
animationDuration?: number;
/** Disable sorting without destroying. Default: `false` */
disabled?: boolean;
/** Called when sorting begins. */
onSortStart?: (data: SortEventData) => void;
/** Called on each move during sorting. */
onSortMove?: (data: SortEventData) => void;
/** Called when sorting ends. */
onSortEnd?: (data: SortEventData) => void;
}Return Value: SortableHandle
interface SortableHandle {
/** Remove all listeners and clean up. */
destroy: () => void;
/** Temporarily disable sorting. */
disable: () => void;
/** Re-enable sorting after `disable()`. */
enable: () => void;
/** Whether sorting is currently enabled. */
readonly enabled: boolean;
}Examples
Vertical list sorting:
const sort = sortable(document.querySelector('#todo-list')!, {
items: 'li',
axis: 'y',
onSortEnd: ({ oldIndex, newIndex }) => {
console.log(`Moved from ${oldIndex} to ${newIndex}`);
},
});
sort.destroy();Horizontal sortable with handle:
const sort = sortable(document.querySelector('#tabs')!, {
items: '.tab',
axis: 'x',
handle: '.tab-drag-handle',
animationDuration: 300,
onSortEnd: ({ oldIndex, newIndex, container }) => {
console.log('Tab reordered in', container);
},
});Toggle sorting on/off:
const sort = sortable(document.querySelector('#list')!, { items: 'li' });
sort.disable();
console.log(sort.enabled); // false
sort.enable();
console.log(sort.enabled); // trueSupporting Types
DragAxis
type DragAxis = 'x' | 'y' | 'both';DragPosition
interface DragPosition {
x: number;
y: number;
}BoundsRect
interface BoundsRect {
left: number;
top: number;
right: number;
bottom: number;
}DragBounds
type DragBounds = 'parent' | string | BoundsRect;'parent'— constrain to the element's offset parent- A CSS selector string — constrain to the first matching element's bounding rect
- A
BoundsRectobject — constrain to explicit pixel boundaries
DragEventData
interface DragEventData {
/** The element being dragged. */
element: HTMLElement;
/** Current position (accumulated translation). */
position: DragPosition;
/** Movement delta since the last drag event. */
delta: DragPosition;
/** The raw pointer or keyboard event. */
event: PointerEvent | KeyboardEvent;
}DropEventData
interface DropEventData {
/** The drop zone element. */
zone: HTMLElement;
/** The dragged element. */
dragged: HTMLElement;
/** The raw pointer event. */
event: PointerEvent;
}SortEventData
interface SortEventData {
/** The sortable container. */
container: HTMLElement;
/** The item being sorted. */
item: HTMLElement;
/** The item's original index before sorting. */
oldIndex: number;
/** The item's new index after sorting. */
newIndex: number;
}Notes
- Built on pointer events with graceful DOM/environment guards.
- Supports touch devices without requiring external dependencies.
- Ghost offsets and placeholder behavior are covered by the test suite for common edge cases.
- All handles return
destroy()for lifecycle cleanup — call it when the component unmounts. - CSS classes (e.g.,
bq-dragging,bq-drop-over,bq-sort-placeholder) can be customized per instance.
Additions in 1.14.0
Programmatic API
draggable(), droppable(), and sortable() now return richer handles that you can drive imperatively without rebinding listeners.
import { draggable, droppable, sortable } from '@bquery/bquery/dnd';
const drag = draggable(el);
drag.moveTo({ x: 100, y: 50 }); // move programmatically
drag.setBounds('viewport'); // change constraints on the fly
drag.setAxis('x'); // lock to a single axis
drag.reset(); // return to origin
const pos = drag.getPosition();
const sort = sortable(list);
sort.move(0, 3); // move item 0 to index 3
sort.setOrder([2, 0, 1]); // apply an arbitrary permutation
const items = sort.getItems(); // current DOM order
const drop = droppable(zone);
drop.setAccept((el) => el.matches('.draggable')); // mutate the predicate
const over = drop.isOver();
const active = drop.getActiveDragged();Snap-to-grid, delay, and threshold
draggable(el, {
grid: 16, // square grid
// grid: [16, 24], // separate horizontal/vertical steps
delay: 200, // long-press before activating drag
touchStartThreshold: 8, // pixels of pointer movement required first
});Bounds shorthands
bounds now accepts 'viewport' and any HTMLElement reference in addition to 'parent', a CSS selector, and a BoundsRect.
draggable(el, { bounds: 'viewport' });
draggable(el, { bounds: containerEl });Keyboard accessibility
Pass keyboard: true to make a draggable focusable and operable by keyboard. Pickup announces itself via @bquery/bquery/a11y, and aria-grabbed is toggled on the element.
draggable(el, {
keyboard: true,
keyboardStep: 10, // pixels per arrow key (default 10)
});Space/Enter— pick up, drop- Arrow keys — move
Escape— cancel and return to pickup position
Reactive composables
useDraggable, useDroppable, and useSortable wrap their imperative counterparts and expose signals. When called inside an active reactive scope, the handle is destroyed automatically when the scope stops.
import { effectScope } from '@bquery/bquery/reactive';
import { useDraggable, useDroppable, useSortable } from '@bquery/bquery/dnd';
const scope = effectScope();
scope.run(() => {
const { position, isDragging } = useDraggable(boxEl, { bounds: 'parent' });
const { isOver, activeDragged } = useDroppable(zoneEl, { accept: '.task' });
const { order } = useSortable(listEl, { items: 'li' });
});
scope.stop(); // releases everythingFor raw handles created elsewhere, use the draggablePosition() and sortableOrder() adapters. Their listeners are removed automatically inside a reactive scope, and also when you call handle.destroy() manually:
import { draggable, draggablePosition } from '@bquery/bquery/dnd';
const handle = draggable(box);
const position = draggablePosition(box, handle);
// position is a ReadonlySignal<DragPosition>Pitfalls and gotchas
- Touch devices need
touchStartThresholdto distinguish drag from scroll — default is8px. - Keyboard sortable mode requires focusable items (
tabindex="0"); enable it viakeyboard: trueand tunekeyboardStep. bounds: 'viewport'is recomputed onresize; for custom containers pass an element ref.- Programmatic
handleAPIs (activate(),cancel()) bypass user input but still emit lifecycle events. - Sortable lists must use stable keys for animation continuity.
Performance notes
- Pair with motion's
spring()for elastic snap-back without recomputing layout per frame. - Use
gridsnapping to reduce update churn on coarse drag surfaces.
Testing this module
- Use
@bquery/bquery/testing'sfireEvent.keyDown/userEvent.tabfor keyboard sortable tests. - Assert
draggablePositionsignal values directly.
Related modules
- A11y — keyboard navigation primitives.
- Motion — springs and timelines for drag feedback.
- View — declarative
bq-on:pointerdownintegration.
Version history
- 1.15.0 — graduated to Stable: surface frozen for one minor cycle (#143). Keyboard pick up / move / drop / cancel,
aria-grabbedtransitions, andgrid/delay/viewportoptions covered by tests; accessibility statement published; announcements confirmed to route through the shareda11ylive-region announcer. - 1.14.0 — programmatic handle APIs,
grid,delay,touchStartThreshold,keyboard,keyboardStep,'viewport'bounds, reactiveuseDraggable/useDroppable/useSortable.