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';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.14.0 — programmatic handle APIs,
grid,delay,touchStartThreshold,keyboard,keyboardStep,'viewport'bounds, reactiveuseDraggable/useDroppable/useSortable.