Sortable List
Rows you drag into a new order. The lifted row grows by 2% and casts a hairline shadow while its grip runs hot; the others part on springs as it passes their middles, and the drop settles into the gap with a tiny overshoot.
// Sortable List: drag rows to reorder. The lifted row scales up a touch and casts a hairline
// shadow; the others part on springs as it passes their middles, and the drop settles into the
// gap with a tiny overshoot. Keyboard: Space lifts, arrows move, Space drops, Escape cancels,
// with every step announced. On touch a long press lifts, so the page still scrolls.
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import type { KeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode } from 'react';
import { Spring } from '../shared/spring';
import type { SpringConfig } from '../shared/spring';
import { resolveAccent, type Accent } from '../shared/accent';
import { Velocity, clamp, pad2, timeFrame, useAnnouncer, useLatest, useLoop } from '../kit2-kit/kit';
import '../shared/ember.css';
import './sortable-list.css';
export interface SortableItem {
id: string;
label: ReactNode;
/** Plain text for announcements when `label` is not a string. */
text?: string;
/** Small mono note at the right end. */
meta?: ReactNode;
}
export interface SortableListProps {
items: SortableItem[];
onChange?: (items: SortableItem[]) => void;
/** Show the position number before each label. */
numbered?: boolean;
/** Row size. */
size?: 'md' | 'sm';
'aria-label'?: string;
accent?: Accent;
className?: string;
}
/** Rows parting: quick and nearly flat. */
const PART: SpringConfig = { stiffness: 460, damping: 36 };
/** The drop: a tiny overshoot into the slot. */
const DROP: SpringConfig = { stiffness: 520, damping: 25 };
const LIFT: SpringConfig = { stiffness: 520, damping: 34 };
const LONG_PRESS = 320;
const SLOP = 4;
const TOUCH_SLOP = 8;
interface Drag {
id: string;
from: number;
to: number;
mode: 'pointer' | 'keyboard';
pointerId: number;
startY: number;
dy: number;
tops: number[];
heights: number[];
}
interface Pending {
id: string;
pointerId: number;
x: number;
y: number;
touch: boolean;
timer: number;
}
const textOf = (it: SortableItem) => it.text ?? (typeof it.label === 'string' ? it.label : it.id);
const Grip = () => (
<svg viewBox="0 0 10 16" aria-hidden="true">
{[3, 8, 13].map((y) => (
<g key={y}>
<circle cx="3" cy={y} r="1.1" />
<circle cx="7" cy={y} r="1.1" />
</g>
))}
</svg>
);
export function SortableList({
items,
onChange,
numbered = true,
size = 'md',
'aria-label': ariaLabel = 'Sortable list',
accent: accentProp,
className,
}: SortableListProps) {
const accent = resolveAccent(accentProp);
const uid = useId();
const [order, setOrder] = useState(items);
const idsKey = items.map((it) => it.id).join('\u0000');
useEffect(() => setOrder(items), [idsKey]); // eslint-disable-line react-hooks/exhaustive-deps
const [liftedId, setLiftedId] = useState<string | null>(null);
const [say, announce] = useAnnouncer();
const latest = useLatest({ order, onChange });
const listRef = useRef<HTMLUListElement>(null);
const rowRefs = useRef(new Map<string, HTMLLIElement>());
const handleRefs = useRef(new Map<string, HTMLButtonElement>());
const sim = useRef({
y: new Map<string, Spring>(),
lift: new Map<string, Spring>(),
drag: null as Drag | null,
pending: null as Pending | null,
vel: new Velocity(),
/** Offsets to fold into the springs once React has reordered the DOM. */
commit: null as null | { shift: Map<string, number>; refocus: string | null },
settling: null as string | null,
});
const s = sim.current;
const springY = (id: string) => {
let sp = s.y.get(id);
if (!sp) s.y.set(id, (sp = new Spring(0, PART)));
return sp;
};
const springLift = (id: string) => {
let sp = s.lift.get(id);
if (!sp) s.lift.set(id, (sp = new Spring(0, LIFT)));
return sp;
};
/* ------------------------------------------------------------------ layout maths */
/** Where each row would sit (its offset from home) if `from` moved to `to`. */
const shiftsFor = (d: Drag): number[] => {
const { tops, heights, from, to } = d;
const n = tops.length;
const gap = n > 1 ? tops[1] - tops[0] - heights[0] : 0;
const seq: number[] = [];
for (let i = 0; i < n; i++) if (i !== from) seq.push(i);
seq.splice(to, 0, from);
const out = new Array<number>(n).fill(0);
let y = tops[0];
for (const i of seq) {
out[i] = y - tops[i];
y += heights[i] + gap;
}
return out;
};
/** The slot whose neighbours' middles bracket the dragged row's middle. */
const slotFor = (d: Drag): number => {
const c = d.tops[d.from] + d.dy + d.heights[d.from] / 2;
let k = 0;
for (let j = 0; j < d.tops.length; j++) if (j !== d.from && d.tops[j] + d.heights[j] / 2 < c) k++;
return k;
};
/* ------------------------------------------------------------------ frame */
const frame = (dt: number): boolean => {
let moving = false;
const d = s.drag;
const ord = latest.current.order;
const shifts = d ? shiftsFor(d) : null;
for (let i = 0; i < ord.length; i++) {
const id = ord[i].id;
const sy = springY(id);
const sl = springLift(id);
if (d && d.id === id && d.mode === 'pointer') {
sy.set(d.dy);
} else {
sy.target = shifts ? shifts[i] : 0;
if (loop.reduced) sy.set(sy.target);
else if (sy.step(dt)) moving = true;
}
sl.target = d && d.id === id ? 1 : 0;
if (loop.reduced) sl.set(sl.target);
else if (sl.step(dt)) moving = true;
const el = rowRefs.current.get(id);
if (!el) continue;
const lift = Math.max(0, sl.value);
el.style.transform = `translate3d(0,${sy.value.toFixed(2)}px,0) scale(${(1 + 0.02 * lift).toFixed(4)})`;
el.style.setProperty('--em-sort-lift', lift.toFixed(3));
el.style.zIndex = lift > 0.001 || (s.settling === id && moving) ? '2' : '';
}
if (d) moving = true;
if (!moving) s.settling = null;
return moving;
};
const loop = useLoop(
(dt) => timeFrame('sortable-list', () => frame(dt)),
() => {
for (const sp of s.y.values()) sp.set(sp.target);
for (const sp of s.lift.values()) sp.set(sp.target);
},
);
// After a reorder lands in the DOM, fold the old offsets into the springs: nothing moves.
useLayoutEffect(() => {
const c = s.commit;
if (!c) return;
s.commit = null;
for (const [id, shift] of c.shift) {
const sp = springY(id);
sp.value -= shift;
sp.target -= shift;
}
frame(0);
loop.wake();
if (c.refocus) handleRefs.current.get(c.refocus)?.focus({ preventScroll: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [order]);
/* ------------------------------------------------------------------ lift, move, drop */
const measure = (id: string, mode: Drag['mode'], pointerId = -1, startY = 0): Drag | null => {
const ord = latest.current.order;
const from = ord.findIndex((it) => it.id === id);
if (from < 0) return null;
const tops: number[] = [];
const heights: number[] = [];
for (let i = 0; i < ord.length; i++) {
const el = rowRefs.current.get(ord[i].id);
// Layout positions only: offsetTop ignores the transforms of rows still settling.
tops.push(el ? el.offsetTop : 0);
heights.push(el ? el.offsetHeight : 0);
}
return { id, from, to: from, mode, pointerId, startY, dy: springY(id).value, tops, heights };
};
const lift = (id: string, mode: Drag['mode'], pointerId = -1, startY = 0) => {
const d = measure(id, mode, pointerId, startY);
if (!d) return;
// Grab the row where it is, even mid-settle.
d.startY = startY - d.dy;
s.drag = d;
setLiftedId(id);
const ord = latest.current.order;
announce(`${textOf(ord[d.from])} lifted. Position ${d.from + 1} of ${ord.length}. ${mode === 'keyboard' ? 'Arrow keys move it, Space drops it, Escape cancels.' : ''}`);
loop.wake();
};
const finish = (cancel: boolean, velocity = 0) => {
const d = s.drag;
if (!d) return;
s.drag = null;
setLiftedId(null);
const ord = latest.current.order;
const to = cancel ? d.from : d.to;
const shifts = shiftsFor({ ...d, to });
const sp = springY(d.id);
sp.config = DROP;
sp.target = shifts[d.from];
if (d.mode === 'pointer') sp.velocity = velocity;
s.settling = d.id;
if (loop.reduced) sp.set(sp.target);
window.setTimeout(() => (sp.config = PART), 600);
const name = textOf(ord[d.from]);
if (cancel || to === d.from) {
announce(cancel ? `Reorder cancelled. ${name} stays at position ${d.from + 1}.` : `${name} dropped at position ${to + 1} of ${ord.length}.`);
loop.wake();
return;
}
const next = ord.slice();
const [moved] = next.splice(d.from, 1);
next.splice(to, 0, moved);
const shift = new Map<string, number>();
ord.forEach((it, i) => shift.set(it.id, shifts[i]));
s.commit = { shift, refocus: d.mode === 'keyboard' ? d.id : null };
setOrder(next);
latest.current.onChange?.(next);
announce(`${name} dropped at position ${to + 1} of ${ord.length}.`);
loop.wake();
};
/* ------------------------------------------------------------------ pointer */
const clearPending = () => {
const p = s.pending;
if (p) clearTimeout(p.timer);
s.pending = null;
};
const onPointerDown = (e: ReactPointerEvent<HTMLLIElement>, id: string) => {
if (s.drag || (e.pointerType === 'mouse' && e.button !== 0)) return;
clearPending();
const touch = e.pointerType === 'touch';
const p: Pending = { id, pointerId: e.pointerId, x: e.clientX, y: e.clientY, touch, timer: 0 };
if (touch) {
const el = e.currentTarget;
p.timer = window.setTimeout(() => {
if (s.pending !== p) return;
s.pending = null;
try {
el.setPointerCapture(p.pointerId);
} catch {
/* the pointer may already be gone */
}
navigator.vibrate?.(5);
lift(id, 'pointer', p.pointerId, p.y);
}, LONG_PRESS);
}
s.pending = p;
s.vel.reset(e.timeStamp, e.clientX, e.clientY);
};
const onPointerMove = (e: ReactPointerEvent<HTMLLIElement>) => {
const p = s.pending;
if (p && p.pointerId === e.pointerId) {
const dist = Math.hypot(e.clientX - p.x, e.clientY - p.y);
if (p.touch) {
// Moving before the long press completes is a scroll, not a drag.
if (dist > TOUCH_SLOP) clearPending();
return;
}
if (dist < SLOP) return;
clearPending();
e.currentTarget.setPointerCapture(e.pointerId);
lift(p.id, 'pointer', e.pointerId, p.y);
}
const d = s.drag;
if (!d || d.mode !== 'pointer' || d.pointerId !== e.pointerId) return;
const n = d.tops.length;
const minDy = d.tops[0] - d.tops[d.from];
const maxDy = d.tops[n - 1] + d.heights[n - 1] - (d.tops[d.from] + d.heights[d.from]);
const raw = e.clientY - d.startY;
// Past the ends the row follows less and less.
const over = raw < minDy ? raw - minDy : raw > maxDy ? raw - maxDy : 0;
d.dy = clamp(raw, minDy, maxDy) + (over ? Math.sign(over) * 18 * (1 - 1 / (Math.abs(over) / 60 + 1)) : 0);
s.vel.push(e.timeStamp, e.clientX, e.clientY);
const k = slotFor(d);
if (k !== d.to) d.to = k;
loop.wake();
};
const onPointerUp = (e: ReactPointerEvent<HTMLLIElement>) => {
if (s.pending && s.pending.pointerId === e.pointerId) clearPending();
const d = s.drag;
if (!d || d.mode !== 'pointer' || d.pointerId !== e.pointerId) return;
finish(e.type === 'pointercancel', s.vel.read(e.timeStamp).vy);
};
// While a touch drag is live, stop the page from scrolling under it.
useEffect(() => {
const list = listRef.current;
if (!list) return;
const onTouchMove = (e: TouchEvent) => {
if (s.drag?.mode === 'pointer' || s.pending?.touch) {
if (s.drag) e.preventDefault();
}
};
const onKey = (e: globalThis.KeyboardEvent) => {
if (e.key === 'Escape' && s.drag?.mode === 'pointer') finish(true);
};
list.addEventListener('touchmove', onTouchMove, { passive: false });
window.addEventListener('keydown', onKey);
return () => {
list.removeEventListener('touchmove', onTouchMove);
window.removeEventListener('keydown', onKey);
clearPending();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/* ------------------------------------------------------------------ keyboard */
const onHandleKey = (e: KeyboardEvent<HTMLButtonElement>, id: string) => {
const d = s.drag;
const ord = latest.current.order;
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
if (d && d.mode === 'keyboard') finish(false);
else if (!d) lift(id, 'keyboard');
return;
}
if (d && d.mode === 'keyboard' && d.id === id) {
let to: number | null = null;
if (e.key === 'ArrowUp') to = d.to - 1;
else if (e.key === 'ArrowDown') to = d.to + 1;
else if (e.key === 'Home') to = 0;
else if (e.key === 'End') to = ord.length - 1;
else if (e.key === 'Escape' || e.key === 'Tab') {
if (e.key === 'Escape') e.preventDefault();
finish(true);
return;
}
if (to === null) return;
e.preventDefault();
to = clamp(to, 0, ord.length - 1);
if (to === d.to) return;
d.to = to;
announce(`${textOf(ord[d.from])}, position ${to + 1} of ${ord.length}.`);
loop.wake();
return;
}
// Not lifted: arrows walk between handles.
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
const i = ord.findIndex((it) => it.id === id);
const j = clamp(i + (e.key === 'ArrowUp' ? -1 : 1), 0, ord.length - 1);
handleRefs.current.get(ord[j].id)?.focus();
}
};
return (
<div className={`ember em-sort${className ? ` ${className}` : ''}`} data-accent={accent} data-size={size}>
<ul ref={listRef} className="em-sort__list" aria-label={ariaLabel} data-dragging={liftedId ? '' : undefined}>
{order.map((it, i) => (
<li
key={it.id}
ref={(el) => {
if (el) rowRefs.current.set(it.id, el);
else rowRefs.current.delete(it.id);
}}
className="em-sort__row"
data-lifted={liftedId === it.id || undefined}
onPointerDown={(e) => onPointerDown(e, it.id)}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onContextMenu={(e) => {
if (s.pending?.touch || s.drag) e.preventDefault();
}}
>
<span className="em-sort__shadow" aria-hidden="true" />
<button
ref={(el) => {
if (el) handleRefs.current.set(it.id, el);
else handleRefs.current.delete(it.id);
}}
type="button"
className="em-sort__handle"
aria-label={`Reorder ${textOf(it)}, position ${i + 1} of ${order.length}`}
aria-describedby={`${uid}-help`}
aria-pressed={liftedId === it.id}
onKeyDown={(e) => onHandleKey(e, it.id)}
onBlur={() => {
if (s.drag?.mode === 'keyboard' && s.drag.id === it.id && !s.commit) finish(true);
}}
>
<Grip />
</button>
{numbered ? <span className="em-sort__pos em-mono">{pad2(i + 1)}</span> : null}
<span className="em-sort__label">{it.label}</span>
{it.meta ? <span className="em-sort__meta em-mono">{it.meta}</span> : null}
</li>
))}
</ul>
<span id={`${uid}-help`} className="em-sr">
Press Space to lift, the arrow keys to move, Space to drop and Escape to cancel. On touch, press and hold a row to lift it.
</span>
<span className="em-sr" role="status" aria-live="polite">
{say}
</span>
</div>
);
}
Customize
012345678906
md
sm
Space lifts, the arrows move and Space drops, with every step announced; on touch a long press lifts so the page still scrolls.
Install
npx shadcn@latest add https://obsydeui.com/r/sortable-list.jsonUsage
Import it from where the CLI puts it, or copy the files below by hand.
import { SortableList } from '@/components/obsyde/ember/sortable-list/SortableList';
export function Example() {
return (
<SortableList />
);
}
Files
Plain React, TypeScript and CSS. No dependencies. The download keeps these paths.
ember/sortable-list/SortableList.tsxcomponentember/sortable-list/sortable-list.cssstylesember/sortable-list/SortableListDemo.tsxdemoember/kit2-kit/kit.tssharedember/shared/spring.tssharedember/shared/accent.tssharedember/shared/ember.cssshared
API
| Prop | Type | Default |
|---|---|---|
accentAccent | 'ember' | 'volt' | 'arc' | 'ember' |
countRows | number | 6 |
numberedNumbered | boolean | true |
sizeSize | 'md' | 'sm' | 'md' |