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, since
follow's committed offset is internal state a consumer cannot clear. 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
|
Returns the element to its origin: drops the committed follow offset and the inline translate carrying it, cancelling an in-flight drag first. Needed because in follow mode that offset is internal state the DOM cannot reach. |
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, a teardown that keeps the element, or the in-session handle leaving the element. |
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. |