drag-to-reorderDrag to reorder
拖拽排序Dragging an item changes its list position while surrounding items animate toward their prospective slots.
拖动列表项来重新排序,其他项目会移动并让出位置。
drag-to-reorderKeep pickup, placeholder, and drop states clear while reordering.
Dragging an item changes its list position while surrounding items animate toward their prospective slots.
Implement Drag to reorder: Keep pickup, placeholder, and drop states clear while reordering. Use Duration 180ms, Distance 48px, Pickup scale 103%, Easing snap. Trigger it from real pointer, touch, or keyboard interaction. Use semantic HTML, scope CSS to .motion-demo--drag-to-reorder, and mount the provided vanilla JavaScript interaction runtime. Provide equivalent low-motion behavior for prefers-reduced-motion and .force-reduced-motion.
<div class="motion-demo motion-demo--drag-to-reorder" data-motion="drag-to-reorder" aria-label="Drag to reorder demo">
<div><ul class="motion-list" role="listbox" aria-label="Press and drag to reorder. Keyboard users can press Space, then move with the arrow keys." data-reorder-list><li class="motion-list-item" role="option" tabindex="0" aria-grabbed="false" data-reorder-item data-pickup-label="Item picked up. Use arrow keys to move and Space to drop." data-drop-label="Item moved" data-move-label="Item position" data-cancel-label="Action cancelled">Drag item A</li><li class="motion-list-item" role="option" tabindex="0" aria-grabbed="false" data-reorder-item data-pickup-label="Item picked up. Use arrow keys to move and Space to drop." data-drop-label="Item moved" data-move-label="Item position" data-cancel-label="Action cancelled">Drag item B</li><li class="motion-list-item" role="option" tabindex="0" aria-grabbed="false" data-reorder-item data-pickup-label="Item picked up. Use arrow keys to move and Space to drop." data-drop-label="Item moved" data-move-label="Item position" data-cancel-label="Action cancelled">Drag item C</li></ul><span class="motion-status" data-motion-status role="status" aria-live="assertive"></span></div>
</div>.motion-demo.motion-demo--drag-to-reorder {
box-sizing: border-box;
position: relative;
display: grid;
place-items: center;
width: min(100%, 34rem);
min-height: 15rem;
padding: 2rem;
overflow: hidden;
color: #18181b;
background: #fafafa;
border: 1px solid #e4e4e7;
border-radius: 16px;
isolation: isolate;
}
.motion-demo.motion-demo--drag-to-reorder *, .motion-demo.motion-demo--drag-to-reorder *::before, .motion-demo.motion-demo--drag-to-reorder *::after {
box-sizing: border-box;
}
.motion-demo.motion-demo--drag-to-reorder .motion-surface {
position: relative;
display: grid;
gap: 0.65rem;
width: min(100%, 18rem);
padding: 1.25rem;
background: #ffffff;
border: 1px solid #e4e4e7;
border-radius: 16px;
box-shadow: none;
}
.motion-demo.motion-demo--drag-to-reorder .motion-surface strong { font-weight: 500; }
.motion-demo.motion-demo--drag-to-reorder .motion-line {
display: block;
width: 100%;
height: 0.5rem;
background: #e4e4e7;
border-radius: 999px;
}
.motion-demo.motion-demo--drag-to-reorder .motion-line:last-child { width: 62%; }
.motion-demo.motion-demo--drag-to-reorder .motion-button {
position: relative;
min-height: 2.75rem;
padding: 0.75rem 1.1rem;
color: white;
font: inherit;
font-weight: 700;
background: #18181b;
border: 0;
border-radius: 999px;
cursor: pointer;
}
.motion-demo.motion-demo--drag-to-reorder .motion-button:focus-visible { outline: 3px solid rgb(37 99 235 / 32%); outline-offset: 3px; }
.motion-demo.motion-demo--drag-to-reorder .motion-action-row {
display: flex;
justify-content: center;
gap: 0.5rem;
margin-top: 0.85rem;
}
.motion-demo.motion-demo--drag-to-reorder .motion-replay-scene {
width: min(100%, 22rem);
display: grid;
place-items: center;
}
.motion-demo.motion-demo--drag-to-reorder .motion-pausable-scene {
width: min(100%, 22rem);
display: grid;
place-items: center;
}
.motion-demo.motion-demo--drag-to-reorder button.motion-surface {
color: inherit;
font: inherit;
text-align: start;
cursor: pointer;
}
.motion-demo.motion-demo--drag-to-reorder .motion-replay-button,
.motion-demo.motion-demo--drag-to-reorder .motion-undo-button,
.motion-demo.motion-demo--drag-to-reorder .motion-dismiss-button,
.motion-demo.motion-demo--drag-to-reorder .motion-pause-button {
min-height: 2.75rem;
padding: 0.65rem 0.9rem;
color: #18181b;
font: 650 0.78rem/1 ui-sans-serif, system-ui, sans-serif;
background: #ffffff;
border: 1px solid #d4d4d8;
border-radius: 8px;
cursor: pointer;
}
.motion-demo.motion-demo--drag-to-reorder .motion-replay-button:focus-visible,
.motion-demo.motion-demo--drag-to-reorder .motion-undo-button:focus-visible,
.motion-demo.motion-demo--drag-to-reorder .motion-dismiss-button:focus-visible,
.motion-demo.motion-demo--drag-to-reorder .motion-pause-button:focus-visible,
.motion-demo.motion-demo--drag-to-reorder [data-reorder-item]:focus-visible,
.motion-demo.motion-demo--drag-to-reorder [data-swipe-target]:focus-visible {
outline: 3px solid rgb(37 99 235 / 32%);
outline-offset: 3px;
}
.motion-demo.motion-demo--drag-to-reorder .motion-status {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.motion-demo.motion-demo--drag-to-reorder .motion-ripple-ink {
position: absolute;
width: 12px;
height: 12px;
pointer-events: none;
background: rgb(255 255 255 / 72%);
border-radius: 50%;
transform: translate(-50%, -50%) scale(0.95);
opacity: 0;
}
.motion-demo.motion-demo--drag-to-reorder .motion-state-stack { position: relative; width: min(100%, 18rem); height: 9rem; }
.motion-demo.motion-demo--drag-to-reorder .motion-state { position: absolute; inset: 0; display: grid; place-items: center; padding: 1rem; background: #ffffff; border: 1px solid #e4e4e7; border-radius: 16px; }
.motion-demo.motion-demo--drag-to-reorder .motion-state--to { background: #18181b; color: white; }
.motion-demo.motion-demo--drag-to-reorder .motion-scroll-track > span { color: #71717a; font: 600 0.72rem/1.2 ui-monospace, monospace; letter-spacing: 0.08em; text-transform: uppercase; }
.motion-demo.motion-demo--drag-to-reorder .motion-orbit-item { display: grid; place-items: center; width: 2.5rem; height: 2.5rem; color: white; background: #2563eb; border-radius: 50%; }
.motion-demo.motion-demo--drag-to-reorder .motion-text-stack { position: relative; width: min(100%, 18rem); min-height: 4rem; }
.motion-demo.motion-demo--drag-to-reorder svg { width: min(100%, 20rem); height: auto; }
.motion-demo.motion-demo--drag-to-reorder .motion-list { display: grid; gap: 0.5rem; width: min(100%, 18rem); margin: 0; padding: 0; list-style: none; }
.motion-demo.motion-demo--drag-to-reorder .motion-list-item { position: relative; padding: 0.85rem 1rem; touch-action: none; cursor: grab; background: #ffffff; border: 1px solid #e4e4e7; border-radius: 16px; }
.motion-demo.motion-demo--drag-to-reorder .motion-list-item.is-dragging { cursor: grabbing; border-color: #2563eb; background: rgb(37 99 235 / 8%); box-shadow: none; }
.motion-demo.motion-demo--drag-to-reorder .motion-list-item.is-drop-target { border-color: #2563eb; background: rgb(37 99 235 / 8%); box-shadow: none; }
.motion-demo.motion-demo--drag-to-reorder .motion-list-item.is-keyboard-dragging { outline: 3px solid rgb(37 99 235 / 32%); outline-offset: 3px; }
.motion-demo.motion-demo--drag-to-reorder .motion-drop-placeholder { min-height: 2.75rem; border: 1px dashed #2563eb; border-radius: 16px; background: rgb(37 99 235 / 8%); }
@keyframes motion-drag-to-reorder-reduced-fade {
from { opacity: 0.84; }
to { opacity: 1; }
}
.force-reduced-motion .motion-demo.motion-demo--drag-to-reorder {
animation: motion-drag-to-reorder-reduced-fade 160ms cubic-bezier(0.23, 1, 0.32, 1) both !important;
}
.force-reduced-motion .motion-demo.motion-demo--drag-to-reorder *,
.force-reduced-motion .motion-demo.motion-demo--drag-to-reorder *::before,
.force-reduced-motion .motion-demo.motion-demo--drag-to-reorder *::after {
animation: none !important;
animation-delay: 0ms !important;
animation-iteration-count: 1 !important;
transition-property: opacity, color, background-color, border-color !important;
transition-duration: 160ms !important;
transition-delay: 0ms !important;
scroll-behavior: auto !important;
}
.force-reduced-motion .motion-demo.motion-demo--drag-to-reorder .motion-button,
.force-reduced-motion .motion-demo.motion-demo--drag-to-reorder [data-reorder-item],
.force-reduced-motion .motion-demo.motion-demo--drag-to-reorder [data-swipe-target],
.force-reduced-motion .motion-demo.motion-demo--drag-to-reorder [data-spring-target] {
transform: none !important;
}
.force-reduced-motion .motion-demo.motion-demo--drag-to-reorder .motion-path { stroke-dashoffset: 0 !important; }
.force-reduced-motion .motion-demo.motion-demo--drag-to-reorder .motion-word--from { opacity: 0 !important; visibility: hidden; }
.force-reduced-motion .motion-demo.motion-demo--drag-to-reorder .motion-word:not(.motion-word--from) { opacity: 1 !important; filter: none !important; transform: none !important; }
@media (prefers-reduced-motion: reduce) {
.motion-demo.motion-demo--drag-to-reorder {
animation: motion-drag-to-reorder-reduced-fade 160ms cubic-bezier(0.23, 1, 0.32, 1) both !important;
}
.motion-demo.motion-demo--drag-to-reorder *, .motion-demo.motion-demo--drag-to-reorder *::before, .motion-demo.motion-demo--drag-to-reorder *::after {
animation: none !important;
animation-delay: 0ms !important;
animation-iteration-count: 1 !important;
transition-property: opacity, color, background-color, border-color !important;
transition-duration: 160ms !important;
transition-delay: 0ms !important;
scroll-behavior: auto !important;
}
.motion-demo.motion-demo--drag-to-reorder .motion-button,
.motion-demo.motion-demo--drag-to-reorder [data-reorder-item],
.motion-demo.motion-demo--drag-to-reorder [data-swipe-target],
.motion-demo.motion-demo--drag-to-reorder [data-spring-target] {
transform: none !important;
}
.motion-demo.motion-demo--drag-to-reorder .motion-path { stroke-dashoffset: 0 !important; }
.motion-demo.motion-demo--drag-to-reorder .motion-word--from { opacity: 0 !important; visibility: hidden; }
.motion-demo.motion-demo--drag-to-reorder .motion-word:not(.motion-word--from) { opacity: 1 !important; filter: none !important; transform: none !important; }
}(() => {
const config = {
"id": "drag-to-reorder",
"duration": 180,
"distance": 48,
"resistance": 65,
"position": 50,
"stiffness": 220,
"damping": 24,
"mass": 1,
"velocity": 0,
"rippleSize": 220,
"rippleOpacity": 24,
"dragScale": 103,
"autoplay": false
};
const mount = (root, config) => {
const cleanups = [];
const addCleanup = (callback) => cleanups.push(callback);
const cleanup = () => {
for (let index = cleanups.length - 1; index >= 0; index -= 1) {
try { cleanups[index](); } catch { /* keep cleanup reentrant */ }
}
cleanups.length = 0;
};
const on = (target, type, listener, options) => {
target.addEventListener(type, listener, options);
addCleanup(() => target.removeEventListener(type, listener, options));
};
const reduced = () => root.closest(".force-reduced-motion") !== null ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const watchReduced = (callback) => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
const notify = () => callback(reduced());
media.addEventListener("change", notify);
const observer = typeof MutationObserver === "undefined" ? null : new MutationObserver(notify);
let ancestor = root;
while (observer && ancestor) {
observer.observe(ancestor, { attributes: true, attributeFilter: ["class"] });
ancestor = ancestor.parentElement;
}
addCleanup(() => {
media.removeEventListener("change", notify);
if (observer) observer.disconnect();
});
};
const list = root.querySelector("[data-reorder-list]");
const status = root.querySelector("[data-motion-status]");
if (!list) return cleanup;
const items = Array.from(list.querySelectorAll("[data-reorder-item]"));
const initialStatus = status ? status.textContent : "";
const animations = new Set();
let pointerId = null;
let activeItem = null;
let placeholder = null;
let originalNext = null;
let dropTarget = null;
let startX = 0;
let startY = 0;
let keyboardItem = null;
let keyboardStart = -1;
const announce = (message) => { if (status) status.textContent = message; };
const resetStyles = (item) => {
item.classList.remove("is-dragging");
["position", "inset", "width", "height", "zIndex", "pointerEvents", "transform", "opacity"].forEach((property) => {
item.style[property] = "";
});
};
const clearTarget = () => {
if (dropTarget) dropTarget.classList.remove("is-drop-target");
dropTarget = null;
};
const finish = (cancelled) => {
if (pointerId === null || !activeItem || !placeholder) return;
if (root.hasPointerCapture(pointerId)) root.releasePointerCapture(pointerId);
const item = activeItem;
const from = item.getBoundingClientRect();
if (cancelled) list.insertBefore(placeholder, originalNext);
placeholder.replaceWith(item);
resetStyles(item);
const to = item.getBoundingClientRect();
if (cancelled) {
announce(item.dataset.cancelLabel || "Move cancelled");
} else {
const animation = item.animate(
[
{ transform: "translate3d(" + String(from.left - to.left) + "px, " + String(from.top - to.top) + "px, 0)", opacity: 0.94 },
{ transform: "translate3d(0, 0, 0)", opacity: 1 }
],
{ duration: Math.min(180, config.duration || 180), easing: "cubic-bezier(0.23, 1, 0.32, 1)" }
);
animations.add(animation);
const forget = () => animations.delete(animation);
animation.addEventListener("finish", forget, { once: true });
animation.addEventListener("cancel", forget, { once: true });
announce(item.dataset.dropLabel || "Item moved");
item.dispatchEvent(new CustomEvent("motion:reorder", {
bubbles: true,
detail: { order: Array.from(list.querySelectorAll("[data-reorder-item]")).indexOf(item) }
}));
}
clearTarget();
pointerId = null;
activeItem = null;
placeholder = null;
originalNext = null;
item.focus();
};
const canonical = () => {
animations.forEach((animation) => animation.cancel());
clearTarget();
if (activeItem && placeholder) {
if (pointerId !== null && root.hasPointerCapture(pointerId)) root.releasePointerCapture(pointerId);
list.insertBefore(placeholder, originalNext);
placeholder.replaceWith(activeItem);
resetStyles(activeItem);
}
if (keyboardItem && keyboardStart >= 0) {
const siblings = Array.from(list.querySelectorAll("[data-reorder-item]")).filter((item) => item !== keyboardItem);
list.insertBefore(keyboardItem, siblings[keyboardStart] || null);
}
items.forEach((item) => {
resetStyles(item);
item.classList.remove("is-drop-target", "is-keyboard-dragging");
item.setAttribute("aria-grabbed", "false");
});
if (placeholder) placeholder.remove();
pointerId = null;
activeItem = null;
placeholder = null;
originalNext = null;
keyboardItem = null;
keyboardStart = -1;
if (status) status.textContent = initialStatus || "";
};
addCleanup(canonical);
const pointerDown = (event) => {
if (!event.isPrimary || event.button !== 0 || pointerId !== null) return;
const item = event.currentTarget;
const rect = item.getBoundingClientRect();
pointerId = event.pointerId;
activeItem = item;
originalNext = item.nextSibling;
startX = event.clientX;
startY = event.clientY;
placeholder = document.createElement("li");
placeholder.className = "motion-drop-placeholder";
placeholder.setAttribute("aria-hidden", "true");
placeholder.style.height = String(rect.height) + "px";
list.insertBefore(placeholder, item);
root.append(item);
item.classList.add("is-dragging");
Object.assign(item.style, {
position: "fixed",
inset: String(rect.top) + "px auto auto " + String(rect.left) + "px",
width: String(rect.width) + "px",
height: String(rect.height) + "px",
zIndex: "10",
pointerEvents: "none",
opacity: "0.94",
transform: "translate3d(0, 0, 0)"
});
root.setPointerCapture(event.pointerId);
announce(item.dataset.pickupLabel || "Item picked up");
};
const pointerMove = (event) => {
if (event.pointerId !== pointerId || !activeItem || !placeholder) return;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
activeItem.style.transform = "translate3d(" + String(dx) + "px, " + String(dy) + "px, 0) scale(" + String(Math.max(1, config.dragScale / 100)) + ")";
const element = document.elementFromPoint(event.clientX, event.clientY);
const candidate = element ? element.closest("[data-reorder-item]") : null;
if (!candidate || candidate === activeItem || candidate.parentElement !== list) return;
clearTarget();
dropTarget = candidate;
candidate.classList.add("is-drop-target");
const rect = candidate.getBoundingClientRect();
list.insertBefore(placeholder, event.clientY < rect.top + rect.height / 2 ? candidate : candidate.nextSibling);
};
const pointerUp = (event) => { if (event.pointerId === pointerId) finish(false); };
const pointerCancel = (event) => { if (event.pointerId === pointerId) finish(true); };
const rootKeyDown = (event) => {
if (event.key === "Escape" && pointerId !== null) { event.preventDefault(); finish(true); }
};
const itemKeyDown = (event) => {
const item = event.currentTarget;
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
if (keyboardItem === item) {
item.classList.remove("is-keyboard-dragging");
item.setAttribute("aria-grabbed", "false");
announce(item.dataset.dropLabel || "Item moved");
keyboardItem = null;
keyboardStart = -1;
} else if (!keyboardItem) {
keyboardItem = item;
keyboardStart = Array.from(list.querySelectorAll("[data-reorder-item]")).indexOf(item);
item.classList.add("is-keyboard-dragging");
item.setAttribute("aria-grabbed", "true");
announce(item.dataset.pickupLabel || "Item picked up");
}
return;
}
if (keyboardItem !== item) return;
if (event.key === "Escape") {
event.preventDefault();
const siblings = Array.from(list.querySelectorAll("[data-reorder-item]")).filter((candidate) => candidate !== item);
list.insertBefore(item, siblings[keyboardStart] || null);
item.classList.remove("is-keyboard-dragging");
item.setAttribute("aria-grabbed", "false");
announce(item.dataset.cancelLabel || "Move cancelled");
keyboardItem = null;
keyboardStart = -1;
return;
}
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
event.preventDefault();
const siblings = Array.from(list.querySelectorAll("[data-reorder-item]"));
const index = siblings.indexOf(item);
const next = event.key === "ArrowUp" ? index - 1 : index + 1;
if (next < 0 || next >= siblings.length) return;
list.insertBefore(item, event.key === "ArrowUp" ? siblings[next] : siblings[next].nextSibling);
announce(item.dataset.moveLabel || "Position " + String(next + 1));
};
items.forEach((item) => {
on(item, "pointerdown", pointerDown);
on(item, "keydown", itemKeyDown);
});
on(root, "pointermove", pointerMove);
on(root, "pointerup", pointerUp);
on(root, "pointercancel", pointerCancel);
on(root.ownerDocument, "keydown", rootKeyDown);
return cleanup;
};
document.querySelectorAll("[data-motion=\"drag-to-reorder\"]").forEach((root) => {
const cleanupKey = "__motionLexiconCleanup";
if (typeof root[cleanupKey] === "function") root[cleanupKey]();
root[cleanupKey] = mount(root, config);
});
})();drag-to-reorderDragging an item changes its list position while surrounding items animate toward their prospective slots.
拖动列表项来重新排序,其他项目会移动并让出位置。
dragPointer movement directly updates an object's position from grab until release.
从抓取到释放期间,指针移动会直接更新对象位置。
Drag is the general gesture of grabbing and moving an element; Drag to reorder applies it to list ordering.
Remove follow-pointer animation and use pickup state, target highlighting, and an immediate reorder result.
| Parameter | Range / options | Default | Purpose |
|---|---|---|---|
duration | 80ms–1200ms | 180ms | Controls the time required for one motion cycle. |
distance | 0px–120px | 48px | Controls how far the element travels. |
scale | 70%–105% | 103% | Controls scale in the active state. |
ease | Soft / Snap / Calm / Linear | Snap | Controls how velocity changes over time. |