Pointer Drag
stimeo--pointer-drag
The groundwork for dragging: start, move, end and cancel, with the keyboard able to do the same.
The groundwork under everything you grab and move: reordering, swiping, a sheet you pull up. Mouse, finger and pen become one stream. A press that barely moves stays a plain click. The movement can be locked to one axis. The drag keeps following even when the finger leaves the element, and the page never pans out from under it. Interruptions and Esc cancel it cleanly. What a drag means is deliberately left open, so by default nothing is moved or drawn. If the element should simply follow the pointer, one setting turns that on. The keyboard can do the same: Space grabs, the arrow keys move, Space again drops, and Esc puts it back.
Drag the card, or focus it and press Space to grab, the arrow keys to move, and Space again to drop. Esc snaps it back. The following is handled by a setting; this demo's JavaScript only fills the status line.
Keyboard
| Key | Action |
|---|---|
| Space / Enter | Grab the handle, or drop it while grabbed (start / end). |
| ↑ / ↓ / ← / → | While grabbed, emit a synthetic move of keyboardStep px (locked-axis arrows are consumed but emit nothing). |
| Esc | Cancel the grab — and an in-flight pointer drag. |
<%# pointer-drag: the primitive normalizes the drag lifecycle (threshold, axis
lock, pointer capture, touch-action, cancel) and adds a keyboard alternative.
With the opt-in follow value it also moves the card itself — translate on
move, committed on drop, restored on cancel — so the consumer JS below only
mirrors the signal into the status line. The card can travel out of the
dashed area, which the area clips; the reset action brings it back, ending
any drag in flight and clearing the inline translate follow moved it with.
With follow on, a translate the page writes itself in the <x>px <y>px form
works too, since the next drag reads its start back from the inline
translate. The button sits outside the controller element, so its click
reaches the action as a custom event the element's own data-action maps. %>
<div class="pointer-drag-demo">
<p class="pointer-drag-demo__hint"><%= t("components.pointer_drag.demo.hint") %></p>
<div class="pointer-drag-demo__area">
<button type="button" class="pointer-drag-demo__card" id="pointer-drag-card"
data-controller="stimeo--pointer-drag"
data-stimeo--pointer-drag-follow-value="true"
data-action="demo:reset->stimeo--pointer-drag#reset">
<svg class="demo-icon" viewBox="0 0 24 24" fill="currentColor" stroke="none"
aria-hidden="true">
<circle cx="9" cy="6" r="1.5"></circle><circle cx="15" cy="6" r="1.5"></circle>
<circle cx="9" cy="12" r="1.5"></circle><circle cx="15" cy="12" r="1.5"></circle>
<circle cx="9" cy="18" r="1.5"></circle><circle cx="15" cy="18" r="1.5"></circle>
</svg>
<%= t("components.pointer_drag.demo.card_label") %>
</button>
</div>
<p class="pointer-drag-demo__actions">
<button type="button" class="demo-trigger" data-pointer-drag-reset>
<%= t("components.pointer_drag.demo.reset_label") %>
</button>
</p>
<p class="pointer-drag-demo__status" data-pointer-drag-status aria-live="polite"
data-moved-template="<%= t("components.pointer_drag.demo.moved_template") %>"
data-idle-text="<%= t("components.pointer_drag.demo.idle_text") %>"></p>
</div>
/*
* Presentation-only styles for the pointer-drag demo. The library emits the drag
* signal and flips data-dragging / data-grabbed; demo.js applies the transform.
* This CSS draws the play area and highlights the card while a session is live.
*/
.pointer-drag-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: flex-start;
}
.pointer-drag-demo__hint {
margin: 0;
color: var(--muted);
}
.pointer-drag-demo__area {
width: 100%;
min-height: 10rem;
padding: 1rem;
border: 1px dashed var(--border);
border-radius: 0.5rem;
/* The card translates freely; keep the play area its clipping context. */
overflow: hidden;
}
.pointer-drag-demo__actions {
margin: 0;
}
.pointer-drag-demo__card {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 1px solid var(--border-strong);
border-radius: 0.375rem;
background: var(--bg);
color: var(--fg);
font-size: 1rem;
cursor: grab;
transition: border-color 0.15s ease, color 0.15s ease;
}
.pointer-drag-demo__card:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Live session: pointer drag (data-dragging) or keyboard grab (data-grabbed). */
.pointer-drag-demo__card[data-dragging],
.pointer-drag-demo__card[data-grabbed] {
border-color: var(--accent);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
cursor: grabbing;
}
.pointer-drag-demo__card .demo-icon {
width: 1rem;
height: 1rem;
color: var(--muted);
}
.pointer-drag-demo__status {
margin: 0;
min-height: 1.25rem;
font-size: 0.9rem;
color: var(--muted);
}
// pointer-drag emits the normalized signal (cumulative dx/dy from the origin —
// pointer drags and keyboard arrows alike) and, with the opt-in follow value,
// already moves the card: translate on move, committed on drop, snapped back on
// cancel. No positioning JS is left to write — this consumer only mirrors the
// live deltas into the status line and hands the reset button to the action.
// Status copy comes from the localized data-* templates (values only, never
// words).
const card = document.getElementById("pointer-drag-card");
const status = document.querySelector("[data-pointer-drag-status]");
const resetButton = document.querySelector("[data-pointer-drag-reset]");
const template = status?.dataset.movedTemplate || "{dx}, {dy}";
const idleText = status?.dataset.idleText || "";
const idle = () => {
if (status) status.textContent = idleText;
};
idle();
card?.addEventListener("stimeo--pointer-drag:move", (event) => {
const { dx, dy } = event.detail;
if (status) {
status.textContent = template.replace("{dx}", String(dx)).replace("{dy}", String(dy));
}
});
card?.addEventListener("stimeo--pointer-drag:end", idle);
card?.addEventListener("stimeo--pointer-drag:cancel", idle);
// The button sits outside the controller element, where a data-action cannot
// reach it. Dispatching on the element lets its own data-action run the reset.
resetButton?.addEventListener("click", () => {
card?.dispatchEvent(new CustomEvent("demo:reset"));
});
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--pointer-drag"
Targets
| Name | Description | Attribute |
|---|---|---|
handle
|
The drag handle(s). Optional — without one the controller element itself is the handle. Prefer a real <button>. |
data-stimeo--pointer-drag-target="handle" |
Values
| Name | Description | Attribute |
|---|---|---|
axis
|
Allowed axis: x, y, or both (default). The locked axis' delta is always 0. |
data-stimeo--pointer-drag-axis-value |
threshold
|
Movement in px (along the allowed axis) before a drag starts; below it a press stays a click. Default 3. | data-stimeo--pointer-drag-threshold-value |
keyboardStep
|
Synthetic delta in px per arrow press while grabbed. Default 10. | data-stimeo--pointer-drag-keyboard-step-value |
disabled
|
Ignore all interactions; turning it on mid-session cancels the session. Default false. |
data-stimeo--pointer-drag-disabled-value |
follow
|
Opt-in follow mode: the element tracks the drag via CSS translate — applied on move, committed on drop, restored on cancel (independent of transform, so authored transforms survive). Default false. |
data-stimeo--pointer-drag-follow-value |
Actions
| Name | Description | Action |
|---|---|---|
reset
|
Ends a drag or grab in flight (a started one fires cancel; a press below the threshold ends silently). With follow on it then returns the element to its origin, dropping the committed offset and the inline translate carrying it; with follow off the inline translate is the author's and stays. A gesture in flight answers to the follow it started with. With follow on, a position written into translate as <x>px <y>px needs no reset: the next gesture reads it back as its base. |
stimeo--pointer-drag#reset |
Events
| Name | Description | Event |
|---|---|---|
start
|
Fires with { x, y, pointerType } when the threshold is passed (pointer) or the handle is grabbed (keyboard). |
stimeo--pointer-drag:start |
move
|
Fires with { dx, dy, x, y, pointerType }; deltas are cumulative from the origin, axis-filtered. |
stimeo--pointer-drag:move |
end
|
Fires with { dx, dy, pointerType } on drop (pointerup, or Space/Enter while grabbed). |
stimeo--pointer-drag:end |
cancel
|
Fires with { pointerType } on Escape, pointercancel (OS takeover), disabling mid-session, focus leaving the handle during a grab, a teardown that keeps the element, the in-session handle leaving the element, or reset() ending a started drag or a grab. |
stimeo--pointer-drag:cancel |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
data-dragging |
Controller element | Present while a pointer drag is live (past the threshold). |
data-grabbed |
Controller element / handle | Present while a keyboard grab is live. |
touch-action (inline style) |
Handle | Derived from axis (x→pan-y, y→pan-x, both→none) so the page does not pan mid-drag; authored values win. |