Reorder Drag Wake

Drag reorder

Drag-to-reorder where the dragged row pushes a continuous falloff field through its neighbors — they shoulder sideways and the gap ahead widens before the card ever arrives, instead of a hard placeholder line.

Install
npx shadcn add https://design.helpmarq.com/r/reorder-drag-wake.json
Source
registry/core/reorder-drag-wake/component.tsx
"use client";

import {
  useCallback,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from "react";

// ---------------------------------------------------------------------------
// BowWake — drag-to-reorder where the dragged row pushes a continuous falloff
// field through its neighbors instead of snapping them onto a hard placeholder
// line. Every other row's transform is a function of its distance to the
// dragged card's live center: translateY reflows it into the slot the card
// would land in, translateX shoulders it up to 10px sideways (smoothstep
// falloff over 1.5 row heights) so the gap it will drop into visibly reads as
// the widest point of the wake before the card ever arrives. The dragged card
// gets a 1.01 scale and a velocity-deepened shadow; on release the wake
// collapses inward first (neighbors are already converging on their slots)
// and the card itself settles last, on a short hold, into one underdamped
// spring with a single overshoot. Positions are refs-only, written per-frame
// on a direct-DOM rAF loop that sleeps once every row is at rest — no canvas,
// DOM + CSS transforms only. Distinct from avatar-stack-flock: nothing here trails a
// leader, every neighbor reacts independently to a field around one dragged
// card. Full keyboard reorder ships alongside: a grip button arms discrete
// mode (aria-pressed), arrow keys step the row through the same slots with a
// position announcement, and Escape restores the pre-pickup order. Reduced
// motion turns the field off entirely: rows reflow instantly and a plain
// dashed placeholder marks the drop slot instead.
// ---------------------------------------------------------------------------

export interface BowWakeItem {
  id: string;
  label: string;
  subtitle?: string;
}

export interface BowWakeProps {
  items: BowWakeItem[];
  /** fires whenever the committed order changes — drag drop or a keyboard step */
  onReorder?: (items: BowWakeItem[]) => void;
  className?: string;
  "aria-label"?: string;
}

const ROW_H = 56;
const ROW_GAP = 8;
const STEP = ROW_H + ROW_GAP;
const WAKE_X = 10; // px, max lateral shoulder
const WAKE_RADIUS = STEP * 1.5; // smoothstep falloff distance
const DRAG_THRESHOLD = 4; // px of travel before a pointerdown becomes a live drag
const NEIGHBOR_K = 220;
const NEIGHBOR_C = 2 * 0.92 * Math.sqrt(NEIGHBOR_K); // near-critical, no overshoot
const SETTLE_K = 170;
const SETTLE_C = 2 * 0.58 * Math.sqrt(SETTLE_K); // underdamped — one overshoot
const SETTLE_HOLD_MS = 90; // wake collapses before the dragged card starts settling
const REST_EPS = 0.3;
const REST_VEPS = 2;

function smoothstep(t: number) {
  const x = Math.min(1, Math.max(0, t));
  return x * x * (3 - 2 * x);
}

function buildLiveOrder(base: string[], dragId: string, insertionIndex: number): string[] {
  const rest = base.filter((id) => id !== dragId);
  const clamped = Math.min(rest.length, Math.max(0, insertionIndex));
  rest.splice(clamped, 0, dragId);
  return rest;
}

type Phys = { x: number; y: number; vx: number; vy: number; scale: number };

type DragState = {
  id: string;
  pointerId: number;
  grabOffsetY: number;
  startClientY: number;
  active: boolean;
  baseOrder: string[];
  topY: number;
  insertionIndex: number;
  lastY: number;
  lastT: number;
  vY: number;
};

type SettleState = { startAt: number; targetSlot: number };

function GripIcon() {
  return (
    <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
      <circle cx="9" cy="6" r="1.6" />
      <circle cx="15" cy="6" r="1.6" />
      <circle cx="9" cy="12" r="1.6" />
      <circle cx="15" cy="12" r="1.6" />
      <circle cx="9" cy="18" r="1.6" />
      <circle cx="15" cy="18" r="1.6" />
    </svg>
  );
}

export function BowWake({
  items,
  onReorder,
  className = "",
  "aria-label": ariaLabel = "Reorderable list",
}: BowWakeProps) {
  const itemsById = useMemo(() => {
    const m = new Map<string, BowWakeItem>();
    for (const it of items) m.set(it.id, it);
    return m;
  }, [items]);

  const [order, setOrder] = useState<string[]>(() => items.map((i) => i.id));
  const [armedId, setArmedId] = useState<string | null>(null);
  const [liveMsg, setLiveMsg] = useState("");
  const [reduced, setReduced] = useState(false);

  const orderRef = useRef(order);
  orderRef.current = order;
  const armedIdRef = useRef<string | null>(null);
  armedIdRef.current = armedId;
  const reducedRef = useRef(false);
  reducedRef.current = reduced;
  const pickupOrderRef = useRef<string[] | null>(null);
  const firstReorder = useRef(true);

  const containerRef = useRef<HTMLDivElement>(null);
  const rowRefs = useRef<Map<string, HTMLDivElement>>(new Map());
  const placeholderRef = useRef<HTMLDivElement>(null);
  const physRef = useRef<Map<string, Phys>>(new Map());
  const dragRef = useRef<DragState | null>(null);
  const settleRef = useRef<Map<string, SettleState>>(new Map());
  const suppressClickRef = useRef(false);
  const rafRef = useRef(0);
  const lastFrameRef = useRef(0);

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const update = () => setReduced(mq.matches);
    update();
    mq.addEventListener("change", update);
    return () => mq.removeEventListener("change", update);
  }, []);

  // announce committed reorders (drag drop or keyboard step), never on mount
  useEffect(() => {
    if (firstReorder.current) {
      firstReorder.current = false;
      return;
    }
    onReorder?.(order.map((id) => itemsById.get(id)).filter((x): x is BowWakeItem => !!x));
  }, [order, itemsById, onReorder]);

  const writeTransform = useCallback((id: string, p: Phys) => {
    const el = rowRefs.current.get(id);
    if (!el) return;
    el.style.transform = `translate3d(${p.x.toFixed(2)}px, ${p.y.toFixed(2)}px, 0) scale(${p.scale.toFixed(3)})`;
  }, []);

  const ensureLoop = useCallback(() => {
    if (rafRef.current) return;
    lastFrameRef.current = 0;
    rafRef.current = requestAnimationFrame(loop);
  }, []);

  // Seed physics for ids that don't have a record yet (mount, or items added)
  // and write their transform synchronously before paint — otherwise every
  // row sits at the browser's default (0,0) for one frame and the whole list
  // reads as a single stacked card until the rAF loop happens to run.
  useLayoutEffect(() => {
    const phys = physRef.current;
    order.forEach((id, i) => {
      let p = phys.get(id);
      if (!p) {
        p = { x: 0, y: i * STEP, vx: 0, vy: 0, scale: 1 };
        phys.set(id, p);
      }
      writeTransform(id, p);
    });
    for (const id of Array.from(phys.keys())) {
      if (!itemsById.has(id)) phys.delete(id);
    }
    ensureLoop();
  }, [order, itemsById, writeTransform, ensureLoop]);

  // dragged-row-only visuals (lift shadow, stacking order) reset the moment
  // a drag ends, whichever way it ends — the settle spring below only owns x/y
  const resetDragVisual = useCallback((id: string) => {
    const el = rowRefs.current.get(id);
    if (!el) return;
    el.style.zIndex = "10";
    el.style.setProperty("--bw-lift", "0.35");
  }, []);

  // eslint-disable-next-line react-hooks/exhaustive-deps -- loop reads refs, deps stable across renders
  function loop(now: number) {
    rafRef.current = 0;
    const dt = lastFrameRef.current ? Math.min(0.032, (now - lastFrameRef.current) / 1000) : 1 / 60;
    lastFrameRef.current = now;

    const ord = orderRef.current;
    const drag = dragRef.current;
    const phys = physRef.current;
    let settled = true;

    const liveOrder = drag && drag.active ? buildLiveOrder(drag.baseOrder, drag.id, drag.insertionIndex) : null;

    if (drag && drag.active) {
      const p = phys.get(drag.id);
      if (p) {
        p.x = 0;
        p.y = drag.topY;
        p.vx = 0;
        p.vy = 0;
        const shadowLift = Math.min(1, Math.abs(drag.vY) / 900);
        const el = rowRefs.current.get(drag.id);
        if (el) {
          el.style.setProperty("--bw-lift", (0.35 + shadowLift * 0.65).toFixed(2));
          el.style.zIndex = "30";
        }
        if (!reducedRef.current) {
          p.scale += (1.01 - p.scale) * Math.min(1, 16 * dt);
        } else {
          p.scale = 1;
        }
        writeTransform(drag.id, p);
      }
      settled = false;

      const ph = placeholderRef.current;
      if (ph && reducedRef.current) {
        ph.style.display = "block";
        ph.style.transform = `translate3d(0, ${(drag.insertionIndex * STEP).toFixed(2)}px, 0)`;
      } else if (ph) {
        ph.style.display = "none";
      }
    } else {
      const ph = placeholderRef.current;
      if (ph) ph.style.display = "none";
    }

    for (const id of ord) {
      if (drag && drag.active && id === drag.id) continue;
      const p = phys.get(id);
      if (!p) continue;

      let targetX = 0;
      let targetY: number;

      if (liveOrder && drag) {
        const slot = liveOrder.indexOf(id);
        targetY = slot * STEP;
        const dist = Math.abs(p.y - drag.topY);
        const falloff = reducedRef.current ? 0 : smoothstep(1 - dist / WAKE_RADIUS);
        const sign = p.y < drag.topY ? -1 : 1;
        targetX = sign * WAKE_X * falloff;
      } else {
        targetY = ord.indexOf(id) * STEP;
      }

      const settle = !drag ? settleRef.current.get(id) : undefined;
      if (settle) {
        if (now < settle.startAt) {
          p.vx = 0;
          p.vy = 0;
          settled = false;
        } else {
          const targetSlotY = settle.targetSlot * STEP;
          if (reducedRef.current) {
            p.x = 0;
            p.y = targetSlotY;
            p.vx = 0;
            p.vy = 0;
            p.scale = 1;
            settleRef.current.delete(id);
          } else {
            const ax = -SETTLE_K * (p.x - 0) - SETTLE_C * p.vx;
            const ay = -SETTLE_K * (p.y - targetSlotY) - SETTLE_C * p.vy;
            p.vx += ax * dt;
            p.vy += ay * dt;
            p.x += p.vx * dt;
            p.y += p.vy * dt;
            p.scale += (1 - p.scale) * Math.min(1, 10 * dt);
            if (Math.abs(p.x) < REST_EPS && Math.abs(p.y - targetSlotY) < REST_EPS && Math.abs(p.vx) < REST_VEPS && Math.abs(p.vy) < REST_VEPS) {
              p.x = 0;
              p.y = targetSlotY;
              p.vx = 0;
              p.vy = 0;
              p.scale = 1;
              settleRef.current.delete(id);
            } else {
              settled = false;
            }
          }
        }
        writeTransform(id, p);
        continue;
      }

      if (reducedRef.current) {
        p.x = 0;
        p.y = targetY;
        p.vx = 0;
        p.vy = 0;
        p.scale = 1;
      } else {
        const ax = -NEIGHBOR_K * (p.x - targetX) - NEIGHBOR_C * p.vx;
        const ay = -NEIGHBOR_K * (p.y - targetY) - NEIGHBOR_C * p.vy;
        p.vx += ax * dt;
        p.vy += ay * dt;
        p.x += p.vx * dt;
        p.y += p.vy * dt;
        p.scale += (1 - p.scale) * Math.min(1, 12 * dt);
        if (Math.abs(p.x - targetX) > REST_EPS || Math.abs(p.y - targetY) > REST_EPS || Math.abs(p.vx) > REST_VEPS || Math.abs(p.vy) > REST_VEPS) {
          settled = false;
        }
      }
      const el = rowRefs.current.get(id);
      if (el) el.style.zIndex = "10";
      writeTransform(id, p);
    }

    if (!settled || (drag && drag.active)) {
      rafRef.current = requestAnimationFrame(loop);
    }
  }

  const beginDrag = useCallback(
    (id: string, e: ReactPointerEvent<HTMLButtonElement>) => {
      if (armedIdRef.current) return; // no live drag while another row is keyboard-armed
      const container = containerRef.current;
      const row = rowRefs.current.get(id);
      if (!container || !row) return;
      const rowTop = row.getBoundingClientRect().top;
      e.currentTarget.setPointerCapture(e.pointerId);
      dragRef.current = {
        id,
        pointerId: e.pointerId,
        grabOffsetY: e.clientY - rowTop,
        startClientY: e.clientY,
        active: false,
        baseOrder: orderRef.current.slice(),
        topY: rowTop - container.getBoundingClientRect().top,
        insertionIndex: orderRef.current.indexOf(id),
        lastY: 0,
        lastT: 0,
        vY: 0,
      };
    },
    []
  );

  const moveDrag = useCallback(
    (id: string, e: ReactPointerEvent<HTMLButtonElement>) => {
      const d = dragRef.current;
      if (!d || d.id !== id || d.pointerId !== e.pointerId) return;
      const container = containerRef.current;
      if (!container) return;

      if (!d.active && Math.abs(e.clientY - d.startClientY) > DRAG_THRESHOLD) {
        d.active = true;
        suppressClickRef.current = true;
        ensureLoop();
      }
      if (!d.active) return;

      const n = d.baseOrder.length;
      const maxTop = Math.max(0, (n - 1) * STEP);
      const containerTop = container.getBoundingClientRect().top;
      const rawTop = e.clientY - containerTop - d.grabOffsetY;
      d.topY = Math.min(maxTop, Math.max(0, rawTop));
      d.insertionIndex = Math.min(n - 1, Math.max(0, Math.round(d.topY / STEP)));

      const now = performance.now();
      if (d.lastT) {
        const dt = (now - d.lastT) / 1000;
        if (dt > 0.001) d.vY = (d.topY - d.lastY) / dt;
      }
      d.lastY = d.topY;
      d.lastT = now;
      ensureLoop();
    },
    [ensureLoop]
  );

  const endDrag = useCallback(
    (id: string, e: ReactPointerEvent<HTMLButtonElement>, cancelled: boolean) => {
      const d = dragRef.current;
      if (!d || d.id !== id || d.pointerId !== e.pointerId) return;
      if (e.currentTarget.hasPointerCapture(e.pointerId)) {
        e.currentTarget.releasePointerCapture(e.pointerId);
      }
      dragRef.current = null;

      if (!d.active) return; // it was a click-length press; the click handler arms it
      resetDragVisual(id);

      if (cancelled) {
        settleRef.current.set(id, { startAt: 0, targetSlot: d.baseOrder.indexOf(id) });
        ensureLoop();
        return;
      }

      const finalOrder = buildLiveOrder(d.baseOrder, id, d.insertionIndex);
      settleRef.current.set(id, { startAt: performance.now() + SETTLE_HOLD_MS, targetSlot: finalOrder.indexOf(id) });
      setOrder(finalOrder);
      ensureLoop();
    },
    [ensureLoop, resetDragVisual]
  );

  const armOrDrop = useCallback(
    (id: string) => {
      if (suppressClickRef.current) {
        suppressClickRef.current = false;
        return;
      }
      if (armedIdRef.current && armedIdRef.current !== id) return; // another row already armed

      if (armedIdRef.current === id) {
        const idx = orderRef.current.indexOf(id);
        setLiveMsg(`Dropped. ${itemsById.get(id)?.label ?? ""}, now at position ${idx + 1} of ${orderRef.current.length}.`);
        setArmedId(null);
        pickupOrderRef.current = null;
      } else {
        pickupOrderRef.current = orderRef.current.slice();
        setArmedId(id);
        const idx = orderRef.current.indexOf(id);
        setLiveMsg(
          `Grabbed ${itemsById.get(id)?.label ?? ""}. Now at position ${idx + 1} of ${orderRef.current.length}. Use arrow keys to move, space to drop, escape to cancel.`
        );
      }
    },
    [itemsById]
  );

  const onHandleKeyDown = useCallback(
    (id: string, e: ReactKeyboardEvent<HTMLButtonElement>) => {
      if (armedIdRef.current !== id) return;
      if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
      e.preventDefault();
      const cur = orderRef.current;
      const idx = cur.indexOf(id);
      const swapWith = e.key === "ArrowUp" ? idx - 1 : idx + 1;
      if (swapWith < 0 || swapWith >= cur.length) return;
      const next = cur.slice();
      next[idx] = cur[swapWith];
      next[swapWith] = cur[idx];
      setOrder(next);
      settleRef.current.set(id, { startAt: 0, targetSlot: swapWith });
      const otherId = cur[swapWith];
      settleRef.current.set(otherId, { startAt: 0, targetSlot: idx });
      setLiveMsg(`${itemsById.get(id)?.label ?? ""}, now at position ${swapWith + 1} of ${cur.length}.`);
      ensureLoop();
    },
    [ensureLoop, itemsById]
  );

  // Escape: cancel a live pointer drag, or restore the pre-pickup order while
  // keyboard-armed. Deliberately global (not scoped to focus) — the operation
  // is "in flight" regardless of where focus lands in between.
  useEffect(() => {
    function onKeyDown(e: KeyboardEvent) {
      if (e.key !== "Escape") return;
      const d = dragRef.current;
      if (d) {
        if (d.active) {
          resetDragVisual(d.id);
          settleRef.current.set(d.id, { startAt: 0, targetSlot: d.baseOrder.indexOf(d.id) });
          ensureLoop();
        }
        dragRef.current = null;
        return;
      }
      const armed = armedIdRef.current;
      if (!armed) return;
      const restore = pickupOrderRef.current;
      if (restore) setOrder(restore);
      const idx = restore ? restore.indexOf(armed) : 0;
      setLiveMsg(`Reorder cancelled. ${itemsById.get(armed)?.label ?? ""} restored to position ${idx + 1} of ${restore?.length ?? 0}.`);
      setArmedId(null);
      pickupOrderRef.current = null;
    }
    document.addEventListener("keydown", onKeyDown);
    return () => document.removeEventListener("keydown", onKeyDown);
  }, [itemsById, ensureLoop, resetDragVisual]);

  useEffect(() => {
    return () => {
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
    };
  }, []);

  const containerHeight = Math.max(ROW_H, order.length * STEP - ROW_GAP);

  return (
    <div
      className={`relative ${className}`}
      role="list"
      aria-label={ariaLabel}
      style={{ height: containerHeight }}
      ref={containerRef}
    >
      <div aria-live="polite" role="status" className="sr-only">
        {liveMsg}
      </div>

      <div
        ref={placeholderRef}
        aria-hidden="true"
        style={{ display: "none", height: ROW_H }}
        className="pointer-events-none absolute inset-x-0 top-0 rounded-md border border-dashed border-border/70"
      />

      {order.map((id, rowIdx) => {
        const item = itemsById.get(id);
        if (!item) return null;
        const pos = order.indexOf(id) + 1;
        const isArmed = armedId === id;
        return (
          <div
            key={id}
            ref={(el) => {
              if (el) rowRefs.current.set(id, el);
              else rowRefs.current.delete(id);
            }}
            role="listitem"
            className="absolute inset-x-0 top-0 will-change-transform"
            style={{ height: ROW_H, "--bw-lift": 0.35 } as React.CSSProperties}
          >
            <div
              className={`flex h-full items-center gap-3 rounded-md border bg-surface px-3 transition-colors duration-150 ${
                isArmed ? "border-accent" : "border-border hover:border-foreground/20"
              }`}
              style={{
                boxShadow: `0 calc(6px + 10px * var(--bw-lift)) calc(16px + 10px * var(--bw-lift)) calc(-8px - 2px * var(--bw-lift)) rgba(0,0,0,calc(0.22 + 0.18 * var(--bw-lift)))`,
              }}
            >
              <button
                type="button"
                data-bow-handle
                data-bow-row={rowIdx}
                aria-pressed={isArmed}
                aria-label={`${isArmed ? "Grabbed. " : ""}Reorder ${item.label}. Position ${pos} of ${order.length}.`}
                onPointerDown={(e) => beginDrag(id, e)}
                onPointerMove={(e) => moveDrag(id, e)}
                onPointerUp={(e) => endDrag(id, e, false)}
                onPointerCancel={(e) => endDrag(id, e, true)}
                onClick={() => armOrDrop(id)}
                onKeyDown={(e) => onHandleKeyDown(id, e)}
                className={`flex shrink-0 cursor-grab touch-none select-none items-center justify-center rounded-sm p-1.5 text-muted transition-colors duration-150 hover:bg-foreground/10 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent active:cursor-grabbing ${
                  isArmed ? "bg-accent/10 text-accent" : ""
                }`}
              >
                <GripIcon />
              </button>
              <div className="min-w-0 flex-1">
                <p className="truncate text-sm text-foreground">{item.label}</p>
                {item.subtitle ? (
                  <p className="truncate font-mono text-xs text-muted">{item.subtitle}</p>
                ) : null}
              </div>
              <span className="shrink-0 rounded-full border border-border bg-background px-1.5 py-0.5 font-mono text-[10px] text-muted">
                {pos}
              </span>
            </div>
          </div>
        );
      })}
    </div>
  );
}
Build spec

A vertical drag-to-reorder list for task lists or a kanban lane's cards. RENDERING: absolutely-positioned DOM rows inside one relatively-positioned container (no canvas); every row's transform is written per-frame on a refs-only rAF loop, never React state on the hot path. MECHANISM: picking up a row's grip button and moving the pointer past a 4px threshold starts a live drag — the dragged row tracks the pointer 1:1 on Y (pinned to X=0, scale ramping to 1.01). Every OTHER row is pushed by a continuous field centered on the dragged row's live position: translateY reflows it into the slot the card would occupy if dropped right now (computed by splicing the dragged id into its base order at the pointer-derived insertion index, so the gap between two rows is always exactly the width of one settled row, reading as the drop target before the card arrives — no separate placeholder line); translateX shoulders it up to 10px sideways, sign set by which side of the drag point it sits on, magnitude a smoothstep falloff over 1.5 row-heights (~96px) of distance to the drag point, so a neighbor's push grows and fades continuously as the field sweeps past rather than snapping on or off. Both axes are damped springs (k=220, zeta=0.92) so entering and leaving the field is always continuous, even under fast pointer motion. The dragged card's own shadow deepens with instantaneous drag velocity (a fixed dark rgba ink, not a theme token, so it never inverts to a light halo in dark mode — the same reasoning toast-gravity-stack's shadow uses). DROP: the underlying order commits immediately; neighbors are already converging on their final slots so the wake collapses inward first via the same near-critical spring, while the dragged card itself holds for 90ms and then springs the rest of the way (k=170, zeta=0.58 — one clear overshoot) so it visibly settles last, after the water has closed behind it. Escape while dragging cancels: the card springs back to its pre-pickup slot and the order never commits. A11Y: every row exposes a grip button (`aria-label` names the item and its live position); a click or Space/Enter arms discrete keyboard-reorder mode instead of a live drag (`aria-pressed` on the handle, an `aria-live="polite"` region announces 'Grabbed <item>. Now at position N of M.'); Arrow Up/Down step the armed row through the same slots with a 'now at position N of M' announcement; Space or clicking the handle again drops it and announces the final position; Escape restores the pre-pickup order and announces the restore. Escape is a global document listener (not scoped to focus) since the operation is 'in flight' regardless of where focus sits. REDUCED MOTION: the field is off entirely — rows reflow with an instant snap (no spring, no lateral shoulder) and a plain dashed placeholder box marks the slot the card would drop into while dragging, replacing the wake as the anticipatory cue. Distinct from avatar-stack-flock: nothing here trails a leader on a shared boids sim — every neighbor reacts independently and continuously to a falloff field around one dragged card, and the insertion point is legible before the card lands, not after a leader settles.

Tags
drag-reorderlistkanbanspringphysicskeyboardreordermicro-interaction