UI Components Easy
Drag Handle
A precise drag handle keeps the rest of a row selectable while Pointer Events move the item.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
/* Drag Handle — handle-only pointer reorder */
:root {
--bg: #0b1020;
--panel: #121a2d;
--surface: #18233b;
--surface-2: #1f2c49;
--line: #2b3d62;
--text: #eef2ff;
--muted: #a8b5d1;
--accent: #78a9ff;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
padding: clamp(1rem, 4vw, 3rem);
-webkit-font-smoothing: antialiased;
}
button { font: inherit; }
.demo {
width: min(720px, 100%);
margin: auto;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 22px;
padding: clamp(1.1rem, 3vw, 2rem);
box-shadow: 0 20px 70px #0004;
}
.demo__head h1 {
margin: 0 0 0.35rem;
font-size: clamp(1.3rem, 3vw, 1.7rem);
letter-spacing: -0.02em;
}
.muted { color: var(--muted); margin: 0 0 1.6rem; }
/* ---- list ---- */
.dl {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.dl__row {
display: flex;
align-items: center;
gap: 0.8rem;
padding: 0.7rem 0.9rem 0.7rem 0.45rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 14px;
transition: transform 180ms cubic-bezier(0.2, 0.7, 0.3, 1), border-color 150ms, background 150ms;
will-change: transform;
}
.dl__row:hover { border-color: #3a5183; }
/* row body stays plain selectable text — no grab cursor here */
.dl__title {
flex: 1;
user-select: text;
cursor: text;
}
.dl__tag {
font-size: 0.7rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.2rem 0.55rem;
}
/* ---- handle ---- */
.dl__handle {
flex: none;
width: 2rem;
height: 2rem;
display: grid;
place-items: center;
background: transparent;
border: 0;
border-radius: 8px;
color: #6980a9;
cursor: grab;
touch-action: none;
transition: color 140ms, background 140ms;
}
.dl__handle svg { width: 16px; height: 16px; fill: currentColor; }
.dl__handle:hover { color: var(--text); background: var(--surface-2); }
.dl__handle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; color: var(--text); }
.dl__handle:active { cursor: grabbing; }
/* ---- drag state ---- */
.dl__row.is-dragging {
transition: none;
position: relative;
z-index: 5;
border-color: var(--accent);
background: var(--surface-2);
box-shadow: 0 18px 40px -16px #000c, 0 0 0 1px #78a9ff40;
}
.dl__row.is-bumped { border-color: #405c94; }
/* while dragging, stop the pointer from selecting other rows' text */
.dl.is-active .dl__row .dl__title { user-select: none; cursor: grabbing; }
/* ---- footer ---- */
.dl__hint, .dl__order {
color: var(--muted);
font-size: 0.85rem;
line-height: 1.6;
}
.dl__hint { margin: 1.4rem 0 0.35rem; }
.dl__order { margin: 0; font-variant-numeric: tabular-nums; }
kbd {
font: inherit;
font-size: 0.75rem;
background: var(--surface-2);
border: 1px solid var(--line);
border-bottom-width: 2px;
border-radius: 5px;
padding: 0.05rem 0.35rem;
color: var(--text);
}
@media (prefers-reduced-motion: reduce) {
.dl__row { transition: border-color 150ms, background 150ms; }
}/**
* Drag Handle — reorder a list from a dedicated grip only.
* Pointer Events + pointer capture; rows shift via transform (no reflow while dragging).
* Keyboard fallback: focus a handle and press ArrowUp / ArrowDown.
*/
(function () {
"use strict";
var list = document.getElementById("list");
var orderOut = document.getElementById("order");
if (!list) return;
function rows() {
return Array.prototype.slice.call(list.querySelectorAll(".dl__row"));
}
function report() {
if (!orderOut) return;
orderOut.textContent =
"Order: " +
rows()
.map(function (r, i) {
return i + 1 + ". " + r.querySelector(".dl__title").textContent.trim();
})
.join(" · ");
}
/* ---------------- pointer drag ---------------- */
var drag = null;
list.addEventListener("pointerdown", function (event) {
var handle = event.target.closest(".dl__handle");
if (!handle || (event.button && event.button !== 0)) return;
var row = handle.closest(".dl__row");
var items = rows();
var geometry = items.map(function (el) {
var rect = el.getBoundingClientRect();
return { el: el, height: rect.height, top: rect.top };
});
if (geometry.length < 2) return;
var gap = geometry[1].top - (geometry[0].top + geometry[0].height);
var index = items.indexOf(row);
drag = {
row: row,
geometry: geometry,
step: geometry[index].height + gap,
from: index,
to: index,
startY: event.clientY,
pointerId: event.pointerId
};
handle.setPointerCapture(event.pointerId);
row.classList.add("is-dragging");
list.classList.add("is-active");
event.preventDefault();
});
list.addEventListener("pointermove", function (event) {
if (!drag || event.pointerId !== drag.pointerId) return;
var count = drag.geometry.length;
var min = -drag.from * drag.step;
var max = (count - 1 - drag.from) * drag.step;
var offset = Math.max(min, Math.min(max, event.clientY - drag.startY));
var target = Math.max(0, Math.min(count - 1, drag.from + Math.round(offset / drag.step)));
drag.to = target;
drag.geometry.forEach(function (entry, i) {
if (entry.el === drag.row) {
entry.el.style.transform = "translateY(" + offset + "px)";
return;
}
var shift = 0;
if (target > drag.from && i > drag.from && i <= target) shift = -drag.step;
else if (target < drag.from && i >= target && i < drag.from) shift = drag.step;
entry.el.style.transform = shift ? "translateY(" + shift + "px)" : "";
entry.el.classList.toggle("is-bumped", shift !== 0);
});
});
function endDrag(event) {
if (!drag || (event && event.pointerId !== drag.pointerId)) return;
var d = drag;
drag = null;
d.geometry.forEach(function (entry) {
entry.el.style.transform = "";
entry.el.classList.remove("is-bumped");
});
d.row.classList.remove("is-dragging");
list.classList.remove("is-active");
if (d.to !== d.from) {
var reference = rows()[d.to];
if (d.to > d.from) reference.after(d.row);
else reference.before(d.row);
// Persist here: POST the new order, or lift it into your state store.
report();
}
}
list.addEventListener("pointerup", endDrag);
list.addEventListener("pointercancel", endDrag);
list.addEventListener("lostpointercapture", endDrag);
/* ---------------- keyboard fallback ---------------- */
list.addEventListener("keydown", function (event) {
var handle = event.target.closest(".dl__handle");
if (!handle) return;
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
var row = handle.closest(".dl__row");
var siblings = rows();
var i = siblings.indexOf(row);
var next = event.key === "ArrowUp" ? i - 1 : i + 1;
if (next < 0 || next >= siblings.length) return;
event.preventDefault();
if (next > i) siblings[next].after(row);
else siblings[next].before(row);
handle.focus();
report();
});
report();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Drag Handle</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="Drag Handle">
<header class="demo__head">
<h1>Handle-only drag</h1>
<p class="muted">Only the grip starts a reorder — the row text stays selectable.</p>
</header>
<ul class="dl" id="list" role="list" aria-label="Reorderable task list">
<li class="dl__row" data-id="1">
<button class="dl__handle" type="button" aria-describedby="dl-hint"
aria-label="Reorder: Draft the release notes">
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<circle cx="6" cy="4" r="1.4" /><circle cx="10" cy="4" r="1.4" />
<circle cx="6" cy="8" r="1.4" /><circle cx="10" cy="8" r="1.4" />
<circle cx="6" cy="12" r="1.4" /><circle cx="10" cy="12" r="1.4" />
</svg>
</button>
<span class="dl__title">Draft the release notes</span>
<span class="dl__tag">copy</span>
</li>
<li class="dl__row" data-id="2">
<button class="dl__handle" type="button" aria-describedby="dl-hint"
aria-label="Reorder: Audit pointer capture fallbacks">
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<circle cx="6" cy="4" r="1.4" /><circle cx="10" cy="4" r="1.4" />
<circle cx="6" cy="8" r="1.4" /><circle cx="10" cy="8" r="1.4" />
<circle cx="6" cy="12" r="1.4" /><circle cx="10" cy="12" r="1.4" />
</svg>
</button>
<span class="dl__title">Audit pointer capture fallbacks</span>
<span class="dl__tag">a11y</span>
</li>
<li class="dl__row" data-id="3">
<button class="dl__handle" type="button" aria-describedby="dl-hint"
aria-label="Reorder: Ship the dark theme tokens">
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<circle cx="6" cy="4" r="1.4" /><circle cx="10" cy="4" r="1.4" />
<circle cx="6" cy="8" r="1.4" /><circle cx="10" cy="8" r="1.4" />
<circle cx="6" cy="12" r="1.4" /><circle cx="10" cy="12" r="1.4" />
</svg>
</button>
<span class="dl__title">Ship the dark theme tokens</span>
<span class="dl__tag">design</span>
</li>
<li class="dl__row" data-id="4">
<button class="dl__handle" type="button" aria-describedby="dl-hint"
aria-label="Reorder: Reduce bundle by 12 kilobytes">
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<circle cx="6" cy="4" r="1.4" /><circle cx="10" cy="4" r="1.4" />
<circle cx="6" cy="8" r="1.4" /><circle cx="10" cy="8" r="1.4" />
<circle cx="6" cy="12" r="1.4" /><circle cx="10" cy="12" r="1.4" />
</svg>
</button>
<span class="dl__title">Reduce bundle by 12 KB</span>
<span class="dl__tag">perf</span>
</li>
<li class="dl__row" data-id="5">
<button class="dl__handle" type="button" aria-describedby="dl-hint"
aria-label="Reorder: Write the migration guide">
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<circle cx="6" cy="4" r="1.4" /><circle cx="10" cy="4" r="1.4" />
<circle cx="6" cy="8" r="1.4" /><circle cx="10" cy="8" r="1.4" />
<circle cx="6" cy="12" r="1.4" /><circle cx="10" cy="12" r="1.4" />
</svg>
</button>
<span class="dl__title">Write the migration guide</span>
<span class="dl__tag">launch</span>
</li>
</ul>
<p class="dl__hint" id="dl-hint">
Drag a handle, or focus one and press <kbd>↑</kbd> / <kbd>↓</kbd> to move the row.
</p>
<p class="dl__order" id="order" aria-live="polite"></p>
</main>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
export function DragDropHandle() {
const [items, setItems] = useState(["Alpha", "Beta", "Gamma"]);
return (
<section className="demo">
<h2>Drag Handle</h2>
<p>Vanilla behavior maps cleanly to Pointer Events and DOM state.</p>
<div className="stack">
{items.map((item, index) => (
<button
className="item"
key={item}
onClick={() => setItems([...items.slice(0, index), ...items.slice(index + 1), item])}
>
{item}
</button>
))}
</div>
</section>
);
}Drag Handle
A precise drag handle keeps the rest of a row selectable while Pointer Events move the item.
Support notes
Native HTML5 DnD covers desktop semantics while Pointer Events handle touch-friendly reorder surfaces. Add a persistence callback where the demo updates the DOM.
Included demo
- Vanilla HTML, CSS, and JavaScript with zero external dependencies.
- React equivalent using the same interaction model.
- A Lab route for trying the behavior in isolation.
Integration checklist
Keep state updates separate from presentation, preserve semantic labels, and add persistence or server callbacks at the boundary where your product needs them.