Pointer Drag
stimeo--pointer-drag
A normalized drag lifecycle (start / move / end / cancel) with a built-in keyboard alternative.
The stimeo--pointer-drag controller is the drag primitive everything draggy composes from — sortable, swipe actions, bottom sheets, split panes. It normalizes pointer/touch/mouse into one clean signal and owns the hard parts once: the start threshold (below it a press stays a plain click), axis locking, pointer capture, a touch-action derived from the axis so the page never pans mid-drag, and pointercancel/Escape cancelation. It deliberately does not decide what a drag means: by default it moves nothing and renders nothing — consumers react to the events and the data-dragging / data-grabbed hooks. The one classic case, "just move the element", is absorbed by the opt-in follow value: translate applied on move, committed on drop, restored on cancel (independent of transform). The keyboard alternative is built in: Space or Enter on the handle grabs, the arrow keys emit synthetic moves of keyboardStep pixels (cumulative, axis-filtered), Space/Enter drops, and Escape cancels — so every consumer gets a keyboard path for free. Deltas are cumulative from the origin and physical (client coordinates). Without a handle target, the element itself is the handle.
Drag the card with a pointer, or focus it and press Space, move with the arrow keys, then Space to drop (Esc snaps back). The follow value moves the card — this demo's JS 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. %>
<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">
<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__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__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. 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 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);
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 |
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), or disabling mid-session. |
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. |