Skip to content

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.

ts
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

ts
function draggable(el: HTMLElement, options?: DraggableOptions): DraggableHandle;

Parameters

ParameterTypeDescription
elHTMLElementThe element to make draggable
optionsDraggableOptionsOptional configuration (see below)

DraggableOptions

ts
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

ts
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:

ts
const card = document.querySelector('#card')!;
const drag = draggable(card);

// Later: clean up
drag.destroy();

Constrained to parent with ghost:

ts
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:

ts
const drag = draggable(document.querySelector('#slider')!, {
  axis: 'x',
  handle: '.drag-thumb',
  draggingClass: 'sliding',
});

Custom bounds rectangle:

ts
const drag = draggable(document.querySelector('#box')!, {
  bounds: { left: 0, top: 0, right: 800, bottom: 600 },
});

Toggle dragging on/off:

ts
const drag = draggable(document.querySelector('#card')!);

drag.disable();
console.log(drag.enabled); // false

drag.enable();
console.log(drag.enabled); // true

droppable()

Defines a drop zone that reacts to dragged elements entering, hovering over, leaving, and being dropped onto it.

Signature

ts
function droppable(el: HTMLElement, options?: DroppableOptions): DroppableHandle;

Parameters

ParameterTypeDescription
elHTMLElementThe drop zone element
optionsDroppableOptionsOptional configuration (see below)

DroppableOptions

ts
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

ts
interface DroppableHandle {
  /** Remove all listeners and clean up. */
  destroy: () => void;
}

Examples

Basic drop zone:

ts
const drop = droppable(document.querySelector('#drop-zone')!, {
  onDrop: ({ dragged, zone }) => {
    console.log('Dropped', dragged, 'into', zone);
  },
});

drop.destroy();

Filtered by CSS selector:

ts
const drop = droppable(document.querySelector('#trash')!, {
  accept: '.deletable',
  overClass: 'trash-hover',
  onDrop: ({ dragged }) => {
    dragged.remove();
  },
});

Filtered by function:

ts
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

ts
function sortable(container: HTMLElement, options?: SortableOptions): SortableHandle;

Parameters

ParameterTypeDescription
containerHTMLElementThe container whose children become sortable
optionsSortableOptionsOptional configuration (see below)

SortableOptions

ts
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

ts
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:

ts
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:

ts
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:

ts
const sort = sortable(document.querySelector('#list')!, { items: 'li' });

sort.disable();
console.log(sort.enabled); // false

sort.enable();
console.log(sort.enabled); // true

Supporting Types

DragAxis

ts
type DragAxis = 'x' | 'y' | 'both';

DragPosition

ts
interface DragPosition {
  x: number;
  y: number;
}

BoundsRect

ts
interface BoundsRect {
  left: number;
  top: number;
  right: number;
  bottom: number;
}

DragBounds

ts
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 BoundsRect object — constrain to explicit pixel boundaries

DragEventData

ts
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

ts
interface DropEventData {
  /** The drop zone element. */
  zone: HTMLElement;
  /** The dragged element. */
  dragged: HTMLElement;
  /** The raw pointer event. */
  event: PointerEvent;
}

SortEventData

ts
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.

ts
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

ts
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.

ts
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.

ts
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.

ts
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 everything

For 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:

ts
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 touchStartThreshold to distinguish drag from scroll — default is 8px.
  • Keyboard sortable mode requires focusable items (tabindex="0"); enable it via keyboard: true and tune keyboardStep.
  • bounds: 'viewport' is recomputed on resize; for custom containers pass an element ref.
  • Programmatic handle APIs (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 grid snapping to reduce update churn on coarse drag surfaces.

Testing this module

  • Use @bquery/bquery/testing's fireEvent.keyDown / userEvent.tab for keyboard sortable tests.
  • Assert draggablePosition signal values directly.
  • A11y — keyboard navigation primitives.
  • Motion — springs and timelines for drag feedback.
  • View — declarative bq-on:pointerdown integration.

Version history

  • 1.14.0 — programmatic handle APIs, grid, delay, touchStartThreshold, keyboard, keyboardStep, 'viewport' bounds, reactive useDraggable / useDroppable / useSortable.

Released under the MIT License.