drag
elements man ui/behaviors/dragOne 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.
Signaturefunction 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.ehtmlimport { 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).