Draggable Sortable List
A dependency-free sortable list: lift a row by its grip and the others glide out of the way on a spring, then it settles into its slot. Pointer and touch drag, plus a full keyboard path with live announcements.
DOM + CSS0 deps1 file · 21 KB TSXreduced-motion aware
Props
"use client";
/*
* Draggable sortable list: reorder rows with a grip handle, no dependencies.
*
* Grab the handle and the row lifts (scale + shadow), the rows around it glide
* out of the way on a spring, and on release it settles into its slot. Keyboard:
* focus a handle, press Space to pick the row up, move it with the arrow keys
* (Home and End jump to the ends), Space to drop, Escape to cancel. Every step
* is announced through an aria-live region. Positions are tracked with plain
* pointer events and FLIP animations (Web Animations API).
*/
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
export type SortableListProps = {
/** Row labels, in their starting order. Changing the list resets the order. */
items?: string[];
/** Called with the labels in their new order after every move. */
onChange?: (items: string[]) => void;
/** Surface of the rows: white paper (default) or dark glass. */
tone?: "dark" | "light";
/** Row size: md matches a compact list, lg is a roomy hero-sized list. */
size?: "md" | "lg";
/** Show the position number (01, 02...) at the end of each row. */
showIndex?: boolean;
/** Classes for the root. The default sets the width; pass your own to replace it. */
className?: string;
};
type Row = { id: string; label: string };
const DEFAULT_ITEMS = ["Wireframe", "Design", "Build", "Ship"];
const SEP = "\u0001";
const FALLBACK_EASE = "cubic-bezier(0.34, 1.3, 0.64, 1)";
const SETTLE_MS = 600;
const toRows = (items: string[]): Row[] => items.map((label, i) => ({ id: "r" + i, label }));
const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
function moveItem<T>(arr: T[], from: number, to: number): T[] {
const next = arr.slice();
const [picked] = next.splice(from, 1);
if (picked !== undefined) next.splice(to, 0, picked);
return next;
}
/* A damped spring (stiffness 380, damping 26) sampled into a CSS linear() curve, with a cubic-bezier fallback. */
const SPRING =
"linear(0, 0.046, 0.156, 0.299, 0.452, 0.597, 0.726, 0.832, 0.916, 0.977, 1.018, 1.044, 1.057, 1.06, 1.057, 1.051, 1.042, 1.033, 1.024, 1.016, 1.01, 1.005, 1.001, 0.999, 0.997, 0.997, 0.996, 0.997, 0.997, 0.997, 0.998, 0.999, 0.999, 0.999, 1, 1, 1)";
const CSS = [
".dsl{--dsl-ease:" + FALLBACK_EASE + ";position:relative;font-size:14px}",
"@supports (transition-timing-function:linear(0,1)){.dsl{--dsl-ease:" + SPRING + "}}",
".dsl[data-size=md]{--dsl-h:44px;--dsl-gap:8px;--dsl-r:12px;--dsl-fs:14px;--dsl-hs:32px;--dsl-px:8px;--dsl-grip:16px}",
".dsl[data-size=lg]{--dsl-h:62px;--dsl-gap:10px;--dsl-r:16px;--dsl-fs:17px;--dsl-hs:40px;--dsl-px:11px;--dsl-grip:18px}",
"@media (pointer:coarse){.dsl[data-size=md]{--dsl-h:50px;--dsl-hs:40px}}",
".dsl[data-tone=dark]{--dsl-bg:linear-gradient(180deg,#22242b,#181a20);--dsl-line:rgba(255,255,255,.1);--dsl-line-hi:rgba(255,255,255,.3);--dsl-hl:rgba(255,255,255,.07);--dsl-text:rgba(255,255,255,.92);--dsl-muted:rgba(255,255,255,.34);--dsl-muted-hi:rgba(255,255,255,.85);--dsl-sh:0 1px 0 rgba(0,0,0,.4),0 8px 16px -10px rgba(0,0,0,.7);--dsl-lift:0 26px 44px -14px rgba(0,0,0,.85),0 10px 18px -10px rgba(0,0,0,.7);--dsl-sheen:rgba(255,255,255,.13);--dsl-accent:140,170,255;--dsl-ring:rgba(255,255,255,.9)}",
".dsl[data-tone=light]{--dsl-bg:linear-gradient(180deg,#ffffff,#fafafa);--dsl-line:rgba(15,23,42,.11);--dsl-line-hi:rgba(15,23,42,.34);--dsl-hl:rgba(255,255,255,.9);--dsl-text:#171717;--dsl-muted:#a3a3a3;--dsl-muted-hi:#262626;--dsl-sh:0 1px 2px rgba(15,23,42,.06),0 6px 14px -8px rgba(15,23,42,.12);--dsl-lift:0 26px 40px -16px rgba(15,23,42,.32),0 10px 16px -10px rgba(15,23,42,.18);--dsl-sheen:rgba(15,23,42,.05);--dsl-accent:59,91,255;--dsl-ring:#171717}",
".dsl-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--dsl-gap);position:relative}",
".dsl-li{position:relative;margin:0;padding:0}",
".dsl-li[data-lifted]{z-index:3;will-change:transform}",
".dsl-card{position:relative;display:flex;align-items:center;gap:10px;box-sizing:border-box;min-height:var(--dsl-h);padding:0 calc(var(--dsl-px) + 6px) 0 var(--dsl-px);border-radius:var(--dsl-r);border:1px solid var(--dsl-line);background:var(--dsl-bg);color:var(--dsl-text);box-shadow:var(--dsl-sh),inset 0 1px 0 var(--dsl-hl);transition:transform .34s var(--dsl-ease),box-shadow .3s ease,border-color .2s ease}",
".dsl-card::before{content:\"\";position:absolute;inset:0;border-radius:inherit;pointer-events:none;opacity:0;transition:opacity .3s ease;background:radial-gradient(200px 90px at var(--dsl-mx,50%) 0%,var(--dsl-sheen),transparent 70%)}",
".dsl-li[data-lifted] .dsl-card,.dsl-li[data-grabbed] .dsl-card{border-color:var(--dsl-line-hi);box-shadow:var(--dsl-lift),inset 0 1px 0 var(--dsl-hl)}",
".dsl-li[data-lifted] .dsl-card{transform:scale(1.035)}",
".dsl-li[data-grabbed] .dsl-card{transform:scale(1.025)}",
".dsl-li[data-lifted] .dsl-card::before,.dsl-li[data-grabbed] .dsl-card::before{opacity:1}",
".dsl-li[data-settled] .dsl-card{animation:dsl-settle .9s ease-out}",
"@keyframes dsl-settle{0%{border-color:rgba(var(--dsl-accent),.95);box-shadow:0 0 0 4px rgba(var(--dsl-accent),.2),var(--dsl-sh)}100%{border-color:var(--dsl-line);box-shadow:0 0 0 0 rgba(var(--dsl-accent),0),var(--dsl-sh)}}",
"@media (hover:hover){.dsl-li:hover .dsl-card{border-color:var(--dsl-line-hi)}.dsl-li:hover .dsl-handle{color:var(--dsl-muted-hi)}}",
".dsl-handle{appearance:none;-webkit-appearance:none;position:relative;flex:none;display:grid;place-items:center;width:var(--dsl-hs);height:var(--dsl-hs);margin:0;padding:0;border:0;border-radius:8px;background:transparent;color:var(--dsl-muted);cursor:grab;touch-action:none;user-select:none;-webkit-user-select:none;-webkit-tap-highlight-color:transparent;transition:color .2s ease,background-color .2s ease}",
".dsl-handle::before{content:\"\";position:absolute;inset:-6px}",
".dsl-handle:active,.dsl-li[data-lifted] .dsl-handle,.dsl-li[data-grabbed] .dsl-handle{color:var(--dsl-muted-hi)}",
".dsl-handle:focus-visible{outline:2px solid var(--dsl-ring);outline-offset:2px;border-radius:8px}",
".dsl-grip{width:var(--dsl-grip);height:var(--dsl-grip);display:block}",
".dsl-label{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:var(--dsl-fs);font-weight:500;letter-spacing:-.005em;line-height:1.3}",
".dsl-idx{flex:none;font:500 11px/1 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;letter-spacing:.06em;color:var(--dsl-muted);font-variant-numeric:tabular-nums}",
".dsl[data-size=lg] .dsl-idx{font-size:12px}",
".dsl[data-dragging],.dsl[data-dragging] *{user-select:none;-webkit-user-select:none}",
".dsl[data-dragging] *{cursor:grabbing!important}",
".dsl[data-dragging] .dsl-li:not([data-lifted]){transition:transform .52s var(--dsl-ease)}",
".dsl-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}",
"@media (prefers-reduced-motion:reduce){.dsl-card{transition:box-shadow .15s ease,border-color .15s ease}.dsl-li[data-lifted] .dsl-card,.dsl-li[data-grabbed] .dsl-card{transform:none}.dsl[data-dragging] .dsl-li:not([data-lifted]){transition:none}.dsl-li[data-settled] .dsl-card{animation:none}}",
].join("\n");
type Session = {
id: string;
pointerId: number;
startY: number;
scale: number;
from: number;
target: number;
tops: number[];
heights: number[];
gap: number;
handle: HTMLElement;
};
export function SortableList({
items: initial = DEFAULT_ITEMS,
onChange,
tone = "light",
size = "md",
showIndex = true,
className = "w-72",
}: SortableListProps) {
const itemsKey = initial.join(SEP);
const [rows, setRows] = useState<Row[]>(() => toRows(initial));
// The list can be reset from outside; adjust during render rather than in an effect.
const [seedKey, setSeedKey] = useState(itemsKey);
if (seedKey !== itemsKey) {
setSeedKey(itemsKey);
setRows(toRows(initial));
}
const [grabbed, setGrabbed] = useState<string | null>(null);
const [settled, setSettled] = useState<string | null>(null);
const [message, setMessage] = useState("");
const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
const listRef = useRef<HTMLUListElement>(null);
const rowsRef = useRef(rows);
const grabbedRef = useRef<string | null>(null);
const keyStartRef = useRef<Row[] | null>(null);
const sessionRef = useRef<Session | null>(null);
const pendingRef = useRef<Map<string, number> | null>(null);
const reducedRef = useRef(false);
const onChangeRef = useRef(onChange);
const settleTimer = useRef(0);
const cleanupDrag = useRef<(() => void) | null>(null);
useIsoLayoutEffect(() => {
rowsRef.current = rows;
onChangeRef.current = onChange;
grabbedRef.current = grabbed;
});
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const sync = () => {
reducedRef.current = mq.matches;
};
sync();
mq.addEventListener("change", sync);
return () => mq.removeEventListener("change", sync);
}, []);
useEffect(
() => () => {
window.clearTimeout(settleTimer.current);
cleanupDrag.current?.();
},
[],
);
const lis = useCallback((): HTMLLIElement[] => {
const list = listRef.current;
return list ? (Array.from(list.children) as HTMLLIElement[]) : [];
}, []);
/* Where every row visually is right now, in layout pixels from the list top. */
const captureTops = useCallback((): Map<string, number> => {
const out = new Map<string, number>();
const list = listRef.current;
if (!list) return out;
const lr = list.getBoundingClientRect();
const s = lr.height ? list.offsetHeight / lr.height : 1;
for (const li of lis()) {
const id = li.dataset.rid;
if (id) out.set(id, (li.getBoundingClientRect().top - lr.top) * s);
}
return out;
}, [lis]);
/* FLIP: glide every row from where it looked to where it now is. */
const playFrom = useCallback(
(from: Map<string, number>) => {
if (reducedRef.current) return;
const list = listRef.current;
if (!list) return;
const ease = getComputedStyle(list).getPropertyValue("--dsl-ease").trim() || FALLBACK_EASE;
for (const li of lis()) {
const id = li.dataset.rid;
const was = id ? from.get(id) : undefined;
if (was === undefined || typeof li.animate !== "function") continue;
const delta = was - li.offsetTop;
if (Math.abs(delta) < 0.5) continue;
li.getAnimations().forEach((a) => a.cancel());
li.animate(
[{ transform: "translate3d(0," + delta + "px,0)" }, { transform: "translate3d(0,0,0)" }],
{ duration: SETTLE_MS, easing: ease },
);
}
},
[lis],
);
// A repeated message gets a trailing space so the live region still announces it.
const say = useCallback(
(text: string) => setMessage((prev) => (prev === text ? text + " " : text)),
[],
);
const flash = useCallback((id: string) => {
setSettled(id);
window.clearTimeout(settleTimer.current);
settleTimer.current = window.setTimeout(() => setSettled(null), 900);
}, []);
const applyOrder = useCallback((next: Row[], from: Map<string, number>, flashId: string | null) => {
pendingRef.current = from;
setRows(next);
onChangeRef.current?.(next.map((r) => r.label));
if (flashId) flash(flashId);
}, [flash]);
useIsoLayoutEffect(() => {
const from = pendingRef.current;
pendingRef.current = null;
if (from) playFrom(from);
const id = grabbedRef.current;
if (id) {
const handle = listRef.current?.querySelector<HTMLElement>('[data-rid="' + id + '"] button');
if (handle && document.activeElement !== handle) handle.focus({ preventScroll: true });
}
}, [rows, playFrom]);
/* ---- Pointer dragging ------------------------------------------------ */
const endDrag = useCallback(
(cancel: boolean) => {
const s = sessionRef.current;
const list = listRef.current;
if (!s || !list) return;
sessionRef.current = null;
cleanupDrag.current?.();
cleanupDrag.current = null;
try {
s.handle.releasePointerCapture(s.pointerId);
} catch {
/* capture may already be gone */
}
const vis = captureTops();
for (const li of lis()) {
li.style.transform = "";
delete li.dataset.lifted;
li.style.removeProperty("--dsl-mx");
}
delete list.dataset.dragging;
const order = rowsRef.current;
const to = cancel ? s.from : s.target;
const picked = order[s.from];
if (to !== s.from && picked) {
applyOrder(moveItem(order, s.from, to), vis, picked.id);
say(picked.label + " dropped at position " + (to + 1) + " of " + order.length + ".");
} else {
playFrom(vis);
if (picked) say(cancel ? "Move cancelled." : picked.label + " stays at position " + (s.from + 1) + ".");
}
},
[applyOrder, captureTops, lis, playFrom, say],
);
const onHandleDown = (e: ReactPointerEvent<HTMLButtonElement>, id: string) => {
if (sessionRef.current || grabbedRef.current) return;
if (e.pointerType === "mouse" && e.button !== 0) return;
const list = listRef.current;
if (!list) return;
const els = lis();
const from = els.findIndex((li) => li.dataset.rid === id);
const li = els[from];
if (!li) return;
if (e.pointerType === "mouse") e.preventDefault();
els.forEach((el) => el.getAnimations().forEach((a) => a.cancel()));
const lr = list.getBoundingClientRect();
const handle = e.currentTarget;
const session: Session = {
id,
pointerId: e.pointerId,
startY: e.clientY,
scale: lr.height ? list.offsetHeight / lr.height : 1,
from,
target: from,
tops: els.map((el) => el.offsetTop),
heights: els.map((el) => el.offsetHeight),
gap: parseFloat(getComputedStyle(list).rowGap) || 0,
handle,
};
sessionRef.current = session;
try {
handle.setPointerCapture(e.pointerId);
} catch {
/* ignore */
}
list.dataset.dragging = "";
li.dataset.lifted = "";
const onKey = (ev: KeyboardEvent) => {
if (ev.key === "Escape") {
ev.preventDefault();
endDrag(true);
}
};
window.addEventListener("keydown", onKey);
cleanupDrag.current = () => window.removeEventListener("keydown", onKey);
};
const onHandleMove = (e: ReactPointerEvent<HTMLButtonElement>) => {
const s = sessionRef.current;
if (!s || e.pointerId !== s.pointerId) return;
const els = lis();
const li = els[s.from];
if (!li) return;
const h = s.heights[s.from] ?? 0;
const top = s.tops[s.from] ?? 0;
const first = s.tops[0] ?? 0;
const lastIdx = s.tops.length - 1;
const end = (s.tops[lastIdx] ?? 0) + (s.heights[lastIdx] ?? 0);
const y = Math.min(Math.max((e.clientY - s.startY) * s.scale, first - top), end - (top + h));
li.style.transform = "translate3d(0," + y + "px,0)";
const r = li.getBoundingClientRect();
li.style.setProperty("--dsl-mx", Math.round(((e.clientX - r.left) / (r.width || 1)) * 100) + "%");
const mid = top + y + h / 2;
let target = 0;
for (let j = 0; j < s.tops.length; j++) {
if (j !== s.from && (s.tops[j] ?? 0) + (s.heights[j] ?? 0) / 2 < mid) target++;
}
if (target === s.target) return;
s.target = target;
const shift = h + s.gap;
els.forEach((el, j) => {
if (j === s.from) return;
let d = 0;
if (s.from < target && j > s.from && j <= target) d = -shift;
else if (target < s.from && j >= target && j < s.from) d = shift;
el.style.transform = d ? "translate3d(0," + d + "px,0)" : "";
});
};
const onHandleUp = (e: ReactPointerEvent<HTMLButtonElement>) => {
const s = sessionRef.current;
if (!s || e.pointerId !== s.pointerId) return;
endDrag(e.type === "pointercancel");
};
/* ---- Keyboard sorting ------------------------------------------------ */
const keyMove = (id: string, to: number) => {
const order = rowsRef.current;
const from = order.findIndex((r) => r.id === id);
const row = order[from];
if (!row || from < 0) return;
const next = Math.min(Math.max(to, 0), order.length - 1);
if (next === from) {
say(row.label + " is already " + (next === 0 ? "first" : "last") + ".");
return;
}
applyOrder(moveItem(order, from, next), captureTops(), null);
say(row.label + ", moved to position " + (next + 1) + " of " + order.length + ".");
};
const drop = (id: string) => {
const order = rowsRef.current;
const at = order.findIndex((r) => r.id === id);
const row = order[at];
keyStartRef.current = null;
setGrabbed(null);
if (!row) return;
flash(id);
say(row.label + " dropped at position " + (at + 1) + " of " + order.length + ".");
};
const onHandleKey = (e: ReactKeyboardEvent<HTMLButtonElement>, id: string) => {
if (sessionRef.current) return;
const order = rowsRef.current;
const at = order.findIndex((r) => r.id === id);
const row = order[at];
if (!row) return;
const isGrabbed = grabbedRef.current === id;
const k = e.key;
if (!isGrabbed) {
if (k === " " || k === "Enter") {
e.preventDefault();
keyStartRef.current = order;
setGrabbed(id);
say(
"Picked up " + row.label + ", position " + (at + 1) + " of " + order.length +
". Use the arrow keys to move, space to drop, escape to cancel.",
);
} else if (k === "ArrowUp" || k === "ArrowDown") {
e.preventDefault();
const nb = order[at + (k === "ArrowUp" ? -1 : 1)];
if (nb) listRef.current?.querySelector<HTMLElement>('[data-rid="' + nb.id + '"] button')?.focus();
}
return;
}
if (k === "ArrowUp" || k === "ArrowLeft") {
e.preventDefault();
keyMove(id, at - 1);
} else if (k === "ArrowDown" || k === "ArrowRight") {
e.preventDefault();
keyMove(id, at + 1);
} else if (k === "Home") {
e.preventDefault();
keyMove(id, 0);
} else if (k === "End") {
e.preventDefault();
keyMove(id, order.length - 1);
} else if (k === " " || k === "Enter") {
e.preventDefault();
drop(id);
} else if (k === "Escape") {
e.preventDefault();
const start = keyStartRef.current;
keyStartRef.current = null;
setGrabbed(null);
if (start) {
applyOrder(start, captureTops(), null);
const back = start.findIndex((r) => r.id === id);
say("Cancelled. " + row.label + " returned to position " + (back + 1) + " of " + start.length + ".");
}
}
};
const safeTone = tone === "light" ? "light" : "dark";
const safeSize = size === "lg" ? "lg" : "md";
return (
<div className={"dsl " + className} data-tone={safeTone} data-size={safeSize}>
<style>{CSS}</style>
<ul ref={listRef} className="dsl-list" aria-label="Sortable list">
{rows.map((row, i) => (
<li
key={row.id}
className="dsl-li"
data-rid={row.id}
data-grabbed={grabbed === row.id ? "" : undefined}
data-settled={settled === row.id ? "" : undefined}
>
<div className="dsl-card">
<button
type="button"
className="dsl-handle"
aria-label={"Reorder " + row.label + ", position " + (i + 1) + " of " + rows.length}
aria-pressed={grabbed === row.id}
aria-describedby={"dsl-help-" + uid}
onPointerDown={(e) => onHandleDown(e, row.id)}
onPointerMove={onHandleMove}
onPointerUp={onHandleUp}
onPointerCancel={onHandleUp}
onKeyDown={(e) => onHandleKey(e, row.id)}
onBlur={() => {
if (grabbedRef.current === row.id) drop(row.id);
}}
>
<svg className="dsl-grip" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<circle cx="5.5" cy="3.5" r="1.3" />
<circle cx="10.5" cy="3.5" r="1.3" />
<circle cx="5.5" cy="8" r="1.3" />
<circle cx="10.5" cy="8" r="1.3" />
<circle cx="5.5" cy="12.5" r="1.3" />
<circle cx="10.5" cy="12.5" r="1.3" />
</svg>
</button>
<span className="dsl-label">{row.label}</span>
{showIndex ? (
<span className="dsl-idx" aria-hidden="true">
{String(i + 1).padStart(2, "0")}
</span>
) : null}
</div>
</li>
))}
</ul>
<div id={"dsl-help-" + uid} className="dsl-sr">
Press space to pick up, the arrow keys to move, space to drop and escape to cancel.
</div>
<div className="dsl-sr" role="status" aria-live="assertive" aria-atomic="true">
{message}
</div>
</div>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this pattern
SortableList takes an items array and renders one row per label, each with a six-dot grip. Press a grip and the row lifts: it scales to 1.035 with a deeper shadow and a faint sheen that follows the pointer, while the rest of the list glides aside on a damped spring (stiffness 380, damping 26) the moment your row crosses another row’s midpoint. Release and every row settles into its final slot with the same spring, and the dropped row pulses an accent ring. Only the grip starts a drag, so row text stays selectable, and touch works through the same pointer events. Under the hood there is no drag library: positions are measured in layout pixels, so it stays correct inside scaled previews, and the reorder is a FLIP animation (the Web Animations API plays from where each row looked to where it now sits). Added on top of the original: onChange (called with the new labels), tone (light by default, or dark), size (md or lg), showIndex for the 01, 02 numbers, and className, where the default w-72 sets the width. The rows paint their own surface and need no theme tokens or dependencies. Changing items resets the order. Dragging is limited to a single flat list; there are no cross-list moves or auto-scroll.
Curator’s note
Rebuilt in October without Framer Motion or an icon package: the same SortableList and items prop, now with hand-written pointer sorting, spring-settled FLIP motion and a keyboard path.

