UI Components Easy
Sortable Drag List
Reorder a compact list with native HTML5 drag events and an accessible keyboard fallback.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
:root {
color-scheme: dark;
--bg: #0b1020;
--panel: #121a2d;
--row: #18233b;
--row-hover: #1f2c48;
--line: #2b3d62;
--text: #eef2ff;
--muted: #a8b5d1;
--accent: #78a9ff;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(120% 90% at 50% 0%, #16203a 0%, var(--bg) 60%);
color: var(--text);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.demo {
min-height: 100vh;
display: grid;
place-items: center;
padding: clamp(1rem, 4vw, 3rem);
}
.sortable {
width: min(32rem, 100%);
background: var(--panel);
border: 1px solid var(--line);
border-radius: 20px;
padding: clamp(1.1rem, 3vw, 1.75rem);
box-shadow: 0 24px 70px #0006;
}
.sortable__head h1 {
margin: 0 0 .35rem;
font-size: 1.15rem;
letter-spacing: -0.01em;
}
.sortable__hint {
margin: 0 0 1.15rem;
font-size: .82rem;
color: var(--muted);
}
kbd {
font: inherit;
font-size: .72rem;
padding: .05rem .35rem;
border: 1px solid var(--line);
border-bottom-width: 2px;
border-radius: 5px;
background: #0d1526;
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: .55rem;
}
.item {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: .75rem;
padding: .75rem .9rem;
background: var(--row);
border: 1px solid var(--line);
border-radius: 13px;
cursor: grab;
touch-action: none;
font-size: .92rem;
transition: background .16s ease, transform .16s ease, border-color .16s ease, opacity .16s ease;
}
.item:hover { background: var(--row-hover); }
.item:active { cursor: grabbing; }
.item:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.item__grip {
width: 12px;
height: 16px;
background-image: radial-gradient(circle, var(--muted) 1.4px, transparent 1.5px);
background-size: 6px 5px;
opacity: .75;
}
.item__tag {
font-size: .67rem;
text-transform: uppercase;
letter-spacing: .07em;
color: var(--muted);
border: 1px solid var(--line);
border-radius: 999px;
padding: .15rem .55rem;
}
.item.is-dragging {
opacity: .35;
border-style: dashed;
}
.item.is-over {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent);
}
.item.is-grabbed {
background: #24406e;
border-color: var(--accent);
transform: scale(1.015);
box-shadow: 0 10px 24px #0007;
}
.item.is-moved { animation: pop .3s ease; }
@keyframes pop {
0% { transform: scale(.97); background: #2a4a80; }
100% { transform: scale(1); }
}
.order {
display: block;
margin-top: 1.15rem;
padding-top: .9rem;
border-top: 1px solid var(--line);
font-size: .73rem;
color: var(--muted);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
word-break: break-word;
}
@media (prefers-reduced-motion: reduce) {
.item, .item.is-moved { transition: none; animation: none; }
}/* Sortable Drag List — native HTML5 drag & drop + full keyboard fallback. */
(() => {
const list = document.querySelector("#sortable");
const readout = document.querySelector("#order");
if (!list) return;
const items = () => Array.from(list.querySelectorAll(".item"));
const labelOf = (el) => el.querySelector(".item__label").textContent.trim();
let dragged = null;
let grabbed = null; // keyboard-grabbed row
function announce(action) {
if (!readout) return;
const names = items().map((el, i) => `${i + 1}. ${labelOf(el)}`);
readout.textContent = (action ? action + " — " : "") + names.join(" ");
}
function flash(el) {
el.classList.remove("is-moved");
void el.offsetWidth; // restart animation
el.classList.add("is-moved");
el.addEventListener("animationend", () => el.classList.remove("is-moved"), { once: true });
}
function clearOver() {
items().forEach((el) => el.classList.remove("is-over"));
}
/* ---------- pointer / HTML5 drag ---------- */
list.addEventListener("dragstart", (e) => {
const item = e.target.closest(".item");
if (!item) return;
dragged = item;
item.classList.add("is-dragging");
e.dataTransfer.effectAllowed = "move";
// Firefox requires data to be set for the drag to begin.
e.dataTransfer.setData("text/plain", labelOf(item));
});
list.addEventListener("dragover", (e) => {
if (!dragged) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const target = e.target.closest(".item");
if (!target || target === dragged) return;
clearOver();
target.classList.add("is-over");
const box = target.getBoundingClientRect();
const after = e.clientY > box.top + box.height / 2;
list.insertBefore(dragged, after ? target.nextSibling : target);
});
list.addEventListener("dragleave", (e) => {
const target = e.target.closest?.(".item");
if (target) target.classList.remove("is-over");
});
list.addEventListener("drop", (e) => e.preventDefault());
list.addEventListener("dragend", () => {
if (!dragged) return;
dragged.classList.remove("is-dragging");
clearOver();
flash(dragged);
announce(`Moved ${labelOf(dragged)}`);
dragged = null;
});
/* ---------- keyboard fallback ---------- */
function setGrabbed(item, on) {
item.classList.toggle("is-grabbed", on);
item.setAttribute("aria-selected", String(on));
item.setAttribute("aria-grabbed", String(on));
grabbed = on ? item : null;
}
list.addEventListener("keydown", (e) => {
const item = e.target.closest(".item");
if (!item) return;
const all = items();
const i = all.indexOf(item);
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
if (grabbed === item) {
setGrabbed(item, false);
announce(`Dropped ${labelOf(item)} at position ${i + 1}`);
} else {
if (grabbed) setGrabbed(grabbed, false);
setGrabbed(item, true);
announce(`Grabbed ${labelOf(item)}`);
}
return;
}
if (e.key === "Escape" && grabbed === item) {
e.preventDefault();
setGrabbed(item, false);
announce("Cancelled");
return;
}
const dir = e.key === "ArrowUp" ? -1 : e.key === "ArrowDown" ? 1 : 0;
if (!dir) return;
e.preventDefault();
if (grabbed === item) {
const next = i + dir;
if (next < 0 || next >= all.length) return;
if (dir < 0) list.insertBefore(item, all[next]);
else list.insertBefore(all[next], item);
item.focus();
announce(`${labelOf(item)} now at position ${next + 1}`);
} else {
const next = all[i + dir];
if (next) next.focus();
}
});
list.addEventListener("focusout", (e) => {
if (grabbed && !list.contains(e.relatedTarget)) setGrabbed(grabbed, false);
});
announce("Current order");
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sortable Drag List</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="Sortable Drag List">
<section class="sortable" aria-labelledby="sortable-title">
<header class="sortable__head">
<h1 id="sortable-title">Sprint backlog</h1>
<p class="sortable__hint">
Drag a row to reorder, or focus one and press <kbd>Space</kbd> to grab,
then <kbd>↑</kbd> / <kbd>↓</kbd> to move and <kbd>Space</kbd> to drop.
</p>
</header>
<ul class="list" id="sortable" role="listbox" aria-label="Sprint backlog order">
<li class="item" draggable="true" tabindex="0" role="option" aria-selected="false">
<span class="item__grip" aria-hidden="true"></span>
<span class="item__label">Audit onboarding funnel</span>
<span class="item__tag">design</span>
</li>
<li class="item" draggable="true" tabindex="0" role="option" aria-selected="false">
<span class="item__grip" aria-hidden="true"></span>
<span class="item__label">Ship billing webhooks</span>
<span class="item__tag">backend</span>
</li>
<li class="item" draggable="true" tabindex="0" role="option" aria-selected="false">
<span class="item__grip" aria-hidden="true"></span>
<span class="item__label">Rewrite empty states</span>
<span class="item__tag">copy</span>
</li>
<li class="item" draggable="true" tabindex="0" role="option" aria-selected="false">
<span class="item__grip" aria-hidden="true"></span>
<span class="item__label">Cache the search index</span>
<span class="item__tag">perf</span>
</li>
<li class="item" draggable="true" tabindex="0" role="option" aria-selected="false">
<span class="item__grip" aria-hidden="true"></span>
<span class="item__label">Dark mode contrast pass</span>
<span class="item__tag">a11y</span>
</li>
</ul>
<output class="order" id="order" aria-live="polite"></output>
</section>
</main>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
export function DragDropSortableList() {
const [items, setItems] = useState(["Alpha", "Beta", "Gamma"]);
return (
<section className="demo">
<h2>Sortable Drag List</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>
);
}Sortable Drag List
Reorder a compact list with native HTML5 drag events and an accessible keyboard fallback.
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.