Drag Reorder List
A touch list where a press-and-hold lifts a row under the finger, the gap opens live, the edges auto-scroll, and space plus arrow keys do the identical job.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/drag-reorder-list.jsonPrompt
Build a React + TypeScript + Tailwind "DragReorderList" component (lucide-react for
the grip, chevron and lock icons; Pointer Events, no drag-and-drop library).
It is a mobile component, not a small-screen desktop one: on touch a downward drag
already means "scroll", so the whole design is about arbitrating for that gesture,
recovering the rows that go past the fold, and shipping a path for the user who
cannot hold a finger still for a third of a second.
Contract
- export interface DragReorderItem { id: string; title: string; meta?: string;
leading?: React.ReactNode; trailing?: React.ReactNode; locked?: boolean;
lockedReason?: string }
- export interface DragReorderChange { id: string; from: number; to: number;
via: "pointer" | "keyboard" | "button" }
- export interface DragReorderListProps extends React.HTMLAttributes<HTMLDivElement>:
items: DragReorderItem[]; order?: string[]; defaultOrder?: string[];
onOrderChange?: (order: string[], change: DragReorderChange) => void;
variant?: "hold" | "handle" | "buttons" (default "hold"); holdDelay?: number
(default 320, clamped 120..1200); maxHeight?: number | string; haptics?: boolean
(default true); disabled?: boolean; label?: string; emptyLabel?: string;
showPosition?: boolean (default true).
- Order is the state worth owning, so support both: pass `order` and the component
only asks (call onOrderChange and re-render from the consumer's array); pass
`defaultOrder`, or nothing, and it owns the order itself. `items` carries the
content, never the order.
- Reconcile during render, not in an effect: render the given order, drop ids that
have left `items`, append ids that have joined it. An effect that repaired the
order afterwards would paint one frame of the wrong list.
- forwardRef the root, spread the remaining native props, merge className with cn().
Behavior — claiming the gesture
- variant "hold": pointerdown starts a press, nothing else. A hold timer of
holdDelay lifts the row; more than 10px of travel, a pointerup, a pointercancel
or the pointer leaving the control cancels the press first, and the browser keeps
the scroll it was already doing. The press record and its timer are one ref,
read and written synchronously in the handler, so a cancelled press can never be
resurrected by a frame of scheduling lag.
- variant "handle": a 44px grip on the right (thumb side) carrying touch-action:
none. It lifts after 4px of travel with no hold, because a handle is unambiguous,
and the rest of the row keeps scrolling the list.
- variant "buttons": no gesture at all — an up and a down button per row, each 44px,
moving the row one place. This is the tap path for a phone with no keyboard; a
consumer that wants both ships one list and flips `variant` from an Edit toggle.
- At lift: setPointerCapture on the element the press started on (the finger will
leave it), a short haptic pulse via navigator.vibrate where it exists, and an
announcement.
- The one non-pointer listener, and the reason it exists: touch-action is resolved
when the finger lands, so flipping it to none after a 320ms hold changes nothing
about the gesture already in flight and the list would scroll out from under the
row it just lifted. Attach ONE native touchmove listener on the scroller with
{ passive: false } that calls preventDefault() while a pointer lift is active,
guarded by event.cancelable (a touchmove stops being cancelable once the browser
has committed to a scroll). Never register it through React's onTouchMove — that
one is passive and preventDefault there is a no-op plus a console warning.
Behavior — the move itself
- Capture every row's offsetTop and offsetHeight at lift time; the entire gesture is
scaled to them, so uneven row heights work without a fixed-row-height assumption.
Derive the pitch (how far a displaced neighbour travels) as the lifted row's own
height plus the flex gap, read once as tops[1] - tops[0] - heights[0].
- Split the work by frequency: the finger drives the lifted row's transform
imperatively every frame (a 120Hz drag must not re-render the list), while a
crossing — a handful of times per gesture — is React state. Give each row two
boxes: the <li> whose transform React owns (the gap) and an inner div whose
transform this component owns (the finger). They compose, so neither wipes the
other.
- Target slot = compare the lifted row's live centre with the neighbours' ORIGINAL
centres; because every displaced row moves by exactly one pitch, the original
centre is the correct boundary in both directions. On each change: update the
target, re-open the gap, tick the haptics, announce the new position.
- A locked row is a wall, not an obstacle: the scan stops at it, so nothing crosses
a pinned row and a pinned row can never be lifted. Every refusal states its
lockedReason in the live region rather than snapping back in silence.
- Edge auto-scroll: while a pointer lift is active run a rAF loop; inside the top or
bottom 56px of the scroller scroll by 2..16px per frame, proportional to how deep
the finger is in the band, and repaint from the loop (the finger has not moved but
the content has). Fold scrollTop - startScrollTop into the offset so the row keeps
tracking the finger while the list moves. Cancel the loop on drop, on cancel and
on unmount.
- Release commits: build the new id array with a splice, hand it to onOrderChange
with { id, from, to, via }, and settle. Cancel (pointercancel, Esc, focus leaving
a keyboard lift) restores the original slot.
- Settle with FLIP in a useLayoutEffect keyed on a tick bumped by every move: the
gap transforms are cleared in the same commit that moves the rows into exactly
those positions, so kill row transitions for that one frame or the move plays
twice; then invert the moved row against its fresh offsetTop and hand transform
and transition back to the stylesheet in a rAF so it eases to zero. Measuring the
new offsetTop rather than assuming the reorder happened is what makes a controlled
consumer that REFUSES the reorder animate correctly too.
Behavior — the equal paths and the edges
- Keyboard, on the same grab button: Space/Enter lifts and drops, Esc cancels,
Arrow keys move the lifted row (and walk between handles when nothing is lifted),
Home/End send it as far as the nearest pinned row allows. Blur while lifted
cancels — a lift nobody can see must not survive. A pointer drag ignores Space.
- Announce lift, every crossing, the drop, the cancel and every refusal in a polite
role=status region, and clear it after ~4s so an identical next message is spoken
again. Never announce per pixel.
- Nothing is ever natively disabled: `disabled`, a locked row, a single-item list
and a button at the end of its run all use aria-disabled plus a guard, because the
user may be standing on that control and the browser blurs a node the instant it
becomes disabled. A tap that cannot lift explains itself instead of doing nothing.
- Empty list renders emptyLabel. A one-item list refuses politely. Long titles
truncate to one line instead of reflowing the row mid-drag.
- Android raises its context menu on the very press used to lift, so preventDefault
contextmenu while a press or lift is live, and set -webkit-touch-callout: none on
the grab surface.
- Clean up on unmount: hold timer, auto-scroll frame, settle frame, live-region
timer, the touchmove listener. Pointer capture the browser releases itself.
Rendering & styling
- Semantic tokens only, monochrome first: tray `rounded-2xl border bg-muted/40`,
rows `rounded-lg border bg-card`, text-foreground / text-muted-foreground, ring
and shadow for the lifted row. The row in the air is the highest-priority surface
on the screen, so its position counter INVERTS (bg-foreground text-background)
instead of taking a colour. Locked rows read as border-dashed. No hex, rgb, hsl
or oklch anywhere.
- Sizes are phone-first: rows min-h-14, every button 44px of hit area, title 13px,
meta and trailing 11px, counter 10px tabular-nums.
- The scroller pads with env(safe-area-inset-bottom): this list is routinely the
last thing above the home indicator, and the slot down there has to be droppable.
It also keeps the bottom auto-scroll band off the indicator, since the band is
measured from the padding box. touch-pan-y on the scroller, touch-none on the
handle only, overscroll-contain so a drag never chains to the page.
- The lifted row carries NO transition while a finger drives it (easing would put it
200ms behind the touch); a keyboard lift jumps between slots, so there it eases.
Bake the lift scale into the same transform string that the drag writes — a
Tailwind scale utility would be wiped by the inline transform every frame.
- prefers-reduced-motion removes the gap easing and the settle ease through
motion-reduce:transition-none. Reordering, auto-scroll, haptics and every
announcement keep working; only the decoration stops.
Customization levers
- Pick-up model: `variant` is the real axis — "hold" for a list where rows are the
content, "handle" for dense rows or rows that hold their own controls, "buttons"
for an edit mode or an accessibility-first surface.
- Feel: holdDelay (higher on a jittery list, lower on a short one), the 10px hold
slop, the 4px handle threshold, the 56px auto-scroll band and its 2..16px per
frame speed, the lift scale, and the 200ms settle.
- Density: drop `meta`, turn `showPosition` off to give long titles the width back,
swap the 32px leading box for an avatar, or replace the row body wholesale — the
gesture only needs the row box, not what is inside it.
- Rules: `locked` + `lockedReason` model anything pinned (top, bottom or a divider
in the middle); `disabled` freezes the list without unmounting a thing; `haptics`
off for a list that moves often.
- Wiring: `order` + `onOrderChange` for a controlled list that persists (reject a
move by simply not applying it — the row animates home); `defaultOrder` for a
local one. `change.via` tells you which of the three paths a user took, which is
the cheapest way to find out whether anyone is using the gesture at all.Concepts
- Hold-to-claim — on touch a downward drag is a scroll until proven otherwise, so nothing is claimed until a press survives
holdDelaywithout travelling. Movement, a lift, a cancel or the finger sliding off all hand the gesture straight back to the browser. - touch-action is decided at touchdown — its value is locked in when the finger lands, so switching it to
noneafter the hold fires does nothing for the gesture in flight. One non-passivetouchmovelistener cancelling the scroll is the only way to take it over; every other line of gesture logic stays on Pointer Events. - Live gap and pitch — neighbours are pushed aside by exactly one pitch (the lifted row's height plus the flex gap), and the target slot is decided by comparing the lifted row's centre with the neighbours' original centres. That is what makes the gap open where the row will actually land.
- Edge auto-scroll — a phone shows six rows and a thumb. Holding the row inside the top or bottom band scrolls the list underneath it, with the scroll delta folded back into the drag so the row never drifts away from the finger.
- Pinned rows are walls — a locked row cannot be lifted and nothing may cross it, in any of the three paths. The refusal is spoken, not silent, so a row that will not move stops looking broken.
- FLIP settle — the commit clears the gap transforms in the same frame the layout adopts them, so transitions are killed for that frame and the dropped row is inverted against its fresh
offsetTop. Measuring instead of assuming is why a controlled consumer that refuses the reorder still animates home.
Swipe Pager
Full-width paged views under a horizontal drag — the deck tracks the finger 1:1, resists past the first and last page, commits on flick or distance, and reports position through a dot, bar or counter rail.
Swipe To Reply
A chat row whose sideways swipe arms a reply — the row tracks the thumb, a mark fills the gutter behind it, and past the threshold releasing commits and springs the row straight back.