drag

elements man ui/behaviors/drag

One pointer and keyboard model for sliders, resize handles, swipe to dismiss, drawers and sortable cards. A primary-button press on the element (or on its handle) arms the drag; it starts once the pointer has moved threshold px along axis, or after delay ms held within tolerance px, and the callbacks get the point, the distance from the start and the velocity.

Signature
function drag(el: HTMLElement, opts: DragOptions): Drag; type DragAxis = "x" | "y" | "both"; type DragSource = "pointer" | "keyboard";

Options

Option Type Default What it does
handle string | HTMLElement the element Where a press starts the drag: a selector inside the element, or an element.
axis DragAxis "both" Which movement counts.
threshold number 3 px moved before the drag starts. 0 starts on press, as a slider does.
delay number 0 (off) ms held before the drag starts, instead of the threshold.
tolerance number 5 With delay: px the pointer may move while held.
touch { threshold?, delay?, tolerance? } none For touch presses only: replaces threshold, delay and tolerance, so a finger holds before it drags and a swipe still scrolls.
canStart (e: PointerEvent | KeyboardEvent) => boolean none Called with the pointerdown or keydown that would start a drag; false ignores it.
keyboard DragKeyboard | false { mode: "step" } The keyboard model below; false turns keys off.
disabled () => boolean none Returns true while dragging must not start.
dir "ltr" | "rtl" the element's computed direction Reading direction for step mode.
onStart (s: DragState) => void | boolean none The drag is starting. Return false to refuse it.
onMove (s: DragState) => void none The pointer moved, or a grab-mode key moved the item.
onEnd (s: DragState) => void none The pointer was released, or the item dropped.
onCancel (s: DragState) => void none Escape, pointercancel, a lost capture, blur, a tab switch, a resize, or cancel().
onStep (step: DragStep) => void none Step mode: one keyboard step.
label () => string aria-label, else the text Grab mode: the item's name in announcements.
announcements DragAnnouncements | false the defaults Grab mode: start, move, end and cancel functions of (label, state) returning the message, merged over the defaults ("Picked up <label>.", "<label> moved.", "<label> dropped.", "Dragging cancelled. <label> returned."); false turns them off.
instructions string | false the default text Grab mode: screen reader instructions wired with aria-describedby; false turns them off.

DragKeyboard:

Option Type Default What it does
mode "step" | "grab" "step" Step: keys call onStep and no drag starts. Grab: keys pick up, move and drop the item through onStart, onMove and onEnd.
step number 25 Grab mode: px per arrow press.
largeStep number 4 * step Grab mode: px per Shift+arrow.
keyboardCodes { start?, cancel?, end? } start ["Space", "Enter"], cancel ["Escape"], end ["Space", "Enter", "Tab"] Grab mode: the KeyboardEvent.code values that pick up, cancel and drop.
scrollIntoView boolean | (() => Element | null) true Grab mode: after a move, scroll the lifted element into view, another element (a placeholder in another column), or nothing.
scrollBehavior "instant" | "smooth" "instant" Grab mode: how that scroll moves. Instant is on screen before the next key.
next (key, point, e) => { x, y } | undefined arrow math by step Grab mode: computes the next point for an arrow key (move to the next column, snap to a slot); undefined keeps the point.

State

Callbacks get a DragState: source (pointer or keyboard), pointerType ("mouse", "pen", "touch" or "keyboard"), startX, startY, the current x and y, dx and dy from the start, velocityX and velocityY in px per ms over the last 100ms, and the event. Coordinates are viewport px. dx and dy are zero on the axis the drag ignores, and a pointer held still before the release has no velocity, so a slow release is never a fling.

onStep gets a DragStep: dx and dy as -1, 0 or 1 (Left and Right follow reading direction), large for Shift+arrow, PageUp or PageDown, edge as "start" for Home and "end" for End, the key and the event. The template decides what a step is.

Pointer

A move across the axis first, past the threshold, gives the gesture back to the page: a vertical scroll is not a horizontal swipe. Once started, the element captures the pointer, text selection is suppressed, the element gets data-dragging, and the click that follows a real drag is swallowed so a dragged card does not also open. A press that never reaches the threshold is a plain click. Set touch-action in CSS on the handle (none, or pan-y for a horizontal swipe): pointer events cannot stop touch scrolling once it starts.

Keyboard

The element must be focusable. A control inside it (a button, link or field that is not the handle) keeps its own keys, so Space on a Delete button in a card presses the button.

Keys Step mode Grab mode
Arrows onStep with a unit direction Move by step px while lifted
Shift+arrows, PageUp, PageDown onStep with large Move by largeStep px
Home, End onStep with edge
Space, Enter Pick up; while lifted, drop (onEnd)
Tab Drop while lifted
Escape, blur Cancel (onCancel)

While lifted the element has aria-pressed="true", scrolls into view after each move, and the pick up, move, drop and cancel are announced through announce (opens in a new tab).

The handle

Member What it does
dragging() True between onStart and onEnd or onCancel.
cancel() Cancels a drag in progress, calling onCancel. release() does too.
release() Stops listening and puts back what the behavior set.

In a template of your own

app/shared/templates/range/index.ehtml
import { drag, type Drag, type DragState, type DragStep } from "@elements/ui/behaviors"; interface RangeState { track?: HTMLElement; d?: Drag; } function clamp(n: number): number { return Math.round(Math.min(100, Math.max(0, n))); } function fromPointer(s: DragState, track: HTMLElement): number { let r = track.getBoundingClientRect(); return clamp(((s.x - r.left) / r.width) * 100); } function fromStep(value: number, k: DragStep): number { if (k.edge) { return k.edge === "start" ? 0 : 100; } return clamp(value + k.dx * (k.large ? 10 : 1)); } <Range (value: number = 50, private ui: RangeState = {})> <div class="range" oninsert={(e) => ui.track = e.target as HTMLElement}> <div class="range-thumb" role="slider" tabindex="0" aria-valuemin="0" aria-valuemax="100" aria-valuenow={value} style={`left: ${value}%`} oninsert={(e) => ui.d = drag(e.target as HTMLElement, { axis: "x", threshold: 0, onMove: (s) => value = fromPointer(s, ui.track!), onStep: (k) => value = fromStep(value, k), })} onremove={() => ui.d?.release()}></div> </div> </Range>
app/shared/templates/range/style.css
.range { position: relative; height: var(--space-4); } .range-thumb { position: absolute; top: 0; touch-action: none; }

Writes

data-dragging on the element while a drag runs. In grab mode, aria-pressed while lifted and aria-describedby pointing at one hidden instructions node on <body> per distinct text.

Used by

Carousel (opens in a new tab), ColorPicker (opens in a new tab), DataTable (opens in a new tab), Drawer (opens in a new tab), Kanban (opens in a new tab), Resizable (opens in a new tab), ScrollArea (opens in a new tab), Slider (opens in a new tab), Toast (opens in a new tab).