Drag to Reorder List with Spring Lift and Shadow

A sortable setlist of six songs. Pressing a row's handle lifts it: the row scales up a touch, tilts, gains an orange outline and a deep warm shadow, and follows the pointer while the rows it passes spring into the gap it left. Letting go settles it into place. The position numbers roll to their new values, and the Opens with and Closes with tiles beside the list update as the order changes. The preview picks up and moves rows by itself, with a small pointer on the handle, until a visitor grabs one. On phones the copy stacks above the list and dragging by the handle leaves normal page scrolling alone.

Code

import { useEffect, useRef, useState } from "react";
import type { KeyboardEvent } from "react";
import { AnimatePresence, Reorder, animate, motion, useDragControls, useInView, useMotionValue, useReducedMotion } from "framer-motion";
import { GripVertical, MousePointer2 } from "lucide-react";

type Song = { id: string; title: string; detail: string; seconds: number; from: string; to: string };

// The list. Order here is the starting order; `from` and `to` colour the small cover tile.
const songs: Song[] = [
  { id: "northbound", title: "Northbound", detail: "Key of D, 118 bpm", seconds: 222, from: "#f26a1b", to: "#f7b733" },
  { id: "paper-boats", title: "Paper Boats", detail: "Key of A minor, 92 bpm", seconds: 258, from: "#2f6f6a", to: "#7cc6a4" },
  { id: "glasshouse", title: "Glasshouse", detail: "Key of G, 126 bpm", seconds: 201, from: "#b5362e", to: "#f08a5d" },
  { id: "slow-tide", title: "Slow Tide", detail: "Key of E, 74 bpm", seconds: 304, from: "#33407a", to: "#8fa3e0" },
  { id: "matchlight", title: "Matchlight", detail: "Key of C, 140 bpm", seconds: 187, from: "#8a4a1c", to: "#e2a45c" },
  { id: "last-train", title: "Last Train to Marlow", detail: "Key of F, 104 bpm", seconds: 276, from: "#5b2b5f", to: "#d17fb3" },
];

const SETTLE = { type: "spring", stiffness: 420, damping: 32 } as const; // how rows slide into their new slot
const LIFT = { type: "spring", stiffness: 500, damping: 26 } as const; // how the grabbed row pops up
const REST_SHADOW = "0 1px 2px rgba(43,29,14,0.08), 0 0 0 1px rgba(43,29,14,0.06)";
const LIFT_SHADOW = "0 34px 48px -18px rgba(170,70,10,0.5), 0 14px 22px -14px rgba(43,29,14,0.35), 0 0 0 1.5px rgba(242,106,27,0.9)";
const LIFTED = { scale: 1.04, rotate: -1.2, boxShadow: LIFT_SHADOW };
// Demo: each step picks the row at `from`, carries it to `to`, then lets go.
const DEMO_MOVES = [{ from: 4, to: 1 }, { from: 0, to: 3 }, { from: 5, to: 2 }, { from: 2, to: 0 }, { from: 3, to: 5 }];
const DEMO_STEP_MS = 1450;

const clock = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
const move = <T,>(list: T[], from: number, to: number): T[] => {
  const next = list.slice();
  next.splice(to, 0, next.splice(from, 1)[0]);
  return next;
};

function Row({ song, index, total, held, ghost, still, onGrab, onKey }: { song: Song; index: number; total: number; held: boolean; ghost: boolean; still: boolean; onGrab: () => void; onKey: (e: KeyboardEvent<HTMLButtonElement>) => void }) {
  const controls = useDragControls();
  const y = useMotionValue(0);

  // A held row rises a few pixels. Any offset also puts it above its neighbours while they slide underneath.
  useEffect(() => {
    const a = animate(y, held ? -6 : 0, still ? { duration: 0 } : LIFT);
    return () => a.stop();
  }, [held, still, y]);

  return (
    <Reorder.Item
      value={song}
      as="li"
      dragListener={false}
      dragControls={controls}
      onDragStart={onGrab}
      style={{ y, boxShadow: REST_SHADOW }}
      animate={held ? LIFTED : { scale: 1, rotate: 0, boxShadow: REST_SHADOW }}
      whileDrag={LIFTED}
      transition={still ? { duration: 0 } : { ...LIFT, layout: SETTLE }}
      className="relative flex select-none items-center gap-2 rounded-2xl bg-white py-2.5 pl-1.5 pr-4 sm:gap-3"
    >
      <button
        type="button"
        aria-label={`Reorder ${song.title}, position ${index + 1} of ${total}`}
        aria-pressed={held && !ghost}
        onPointerDown={(e) => {
          onGrab();
          controls.start(e);
        }}
        onKeyDown={onKey}
        className="relative grid h-11 w-9 shrink-0 cursor-grab touch-none place-items-center rounded-xl text-[#a08a6e] hover:bg-[#fbf3e4] hover:text-[#2b1d0e] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#f26a1b] active:cursor-grabbing"
      >
        <GripVertical aria-hidden="true" className="h-5 w-5" />
        <AnimatePresence>
          {ghost && (
            <motion.span aria-hidden="true" className="pointer-events-none absolute left-4 top-5 text-[#2b1d0e]" initial={{ opacity: 0, scale: 1.5, x: 14, y: 14 }} animate={{ opacity: 1, scale: 1, x: 0, y: 0 }} exit={{ opacity: 0, scale: 1.3, x: 10, y: 10 }} transition={{ duration: 0.22 }}>
              <MousePointer2 className="h-6 w-6 drop-shadow" fill="#2b1d0e" stroke="#ffffff" strokeWidth={1.5} />
            </motion.span>
          )}
        </AnimatePresence>
      </button>
      <span className="relative block h-5 w-6 shrink-0 overflow-hidden font-mono text-xs tabular-nums text-[#8a7458]">
        <AnimatePresence initial={false}>
          <motion.span key={index} className="absolute inset-0 leading-5" initial={still ? false : { y: 14, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={still ? { opacity: 0 } : { y: -14, opacity: 0 }} transition={{ duration: 0.25 }}>
            {String(index + 1).padStart(2, "0")}
          </motion.span>
        </AnimatePresence>
      </span>
      <span aria-hidden="true" className="relative h-11 w-11 shrink-0 overflow-hidden rounded-xl" style={{ backgroundImage: `linear-gradient(135deg, ${song.from}, ${song.to})` }}>
        <span className="absolute -bottom-3 -right-3 h-9 w-9 rounded-full border-[5px] border-white/50" />
        <span className="absolute left-2 top-2 h-1.5 w-1.5 rounded-full bg-white/80" />
      </span>
      <span className="min-w-0 flex-1">
        <span className="block truncate text-[15px] font-semibold text-[#2b1d0e]">{song.title}</span>
        <span className="block truncate text-xs text-[#6f5a40]">{song.detail}</span>
      </span>
      <span className="shrink-0 font-mono text-sm tabular-nums text-[#4a3a26]">{clock(song.seconds)}</span>
    </Reorder.Item>
  );
}

export default function DragToReorderListWithSpringLiftAndShadow() {
  const reduce = useReducedMotion();
  const still = !!reduce;
  const root = useRef<HTMLElement>(null);
  const inView = useInView(root, { amount: 0.3 });
  const [items, setItems] = useState(songs);
  const [held, setHeld] = useState<string | null>(null);
  const [demo, setDemo] = useState(true);
  const [said, setSaid] = useState("");
  const step = useRef(0);
  const order = useRef(items);
  order.current = items;

  // Self-playing demo: lift a row, carry it, drop it. Stops the first time someone grabs a handle.
  useEffect(() => {
    if (!demo || still || !inView) return;
    let timers: number[] = [];
    const run = () => {
      const m = DEMO_MOVES[step.current % DEMO_MOVES.length];
      step.current += 1;
      setHeld(order.current[m.from].id);
      timers = [window.setTimeout(() => setItems((list) => move(list, m.from, m.to)), 400), window.setTimeout(() => setHeld(null), 1150)];
    };
    const first = window.setTimeout(run, 100);
    const loop = window.setInterval(run, DEMO_STEP_MS);
    return () => {
      window.clearTimeout(first);
      window.clearInterval(loop);
      timers.forEach((t) => window.clearTimeout(t));
      setHeld(null);
    };
  }, [demo, still, inView]);

  const grab = () => {
    if (demo) {
      setDemo(false);
      setHeld(null);
    }
  };
  // Keyboard: Space or Enter picks a row up, arrows move it, Space, Enter or Escape puts it down.
  const onKey = (song: Song, index: number) => (e: KeyboardEvent<HTMLButtonElement>) => {
    const isHeld = held === song.id && !demo;
    if (e.key === " " || e.key === "Enter") {
      e.preventDefault();
      grab();
      setHeld(isHeld ? null : song.id);
      setSaid(isHeld ? `${song.title} dropped at position ${index + 1}.` : `${song.title} picked up. Use the arrow keys to move it.`);
    } else if (e.key === "Escape" && isHeld) {
      setHeld(null);
      setSaid(`${song.title} dropped at position ${index + 1}.`);
    } else if ((e.key === "ArrowUp" || e.key === "ArrowDown") && isHeld) {
      e.preventDefault();
      const to = Math.max(0, Math.min(items.length - 1, index + (e.key === "ArrowUp" ? -1 : 1)));
      if (to === index) return;
      setItems(move(items, index, to));
      setSaid(`${song.title}, position ${to + 1} of ${items.length}.`);
    }
  };

  const total = items.reduce((n, s) => n + s.seconds, 0);
  const ends = [
    { label: "Opens with", song: items[0] },
    { label: "Closes with", song: items[items.length - 1] },
  ];
  return (
    <section ref={root} className="overflow-hidden bg-[#fbf3e4] px-4 py-14 text-[#2b1d0e] sm:px-6 sm:py-20">
      <div className="mx-auto grid max-w-5xl items-center gap-10 lg:grid-cols-[1fr_minmax(0,460px)] lg:gap-16">
        <div className="max-w-md">
          <p className="font-mono text-xs uppercase tracking-[0.25em] text-[#b84a0c]">The Lanterns / Marlow Hall, Friday</p>
          <h2 className="mt-4 text-4xl font-semibold tracking-tight sm:text-5xl">Build tonight&rsquo;s setlist.</h2>
          <p className="mt-5 text-base text-[#5c4a33] sm:text-lg">Grab a song by its handle and drop it where it belongs. The rest of the set slides out of the way.</p>
          <dl className="mt-8 grid grid-cols-2 gap-3">
            {ends.map((e) => (
              <div key={e.label} className="rounded-2xl border border-[#2b1d0e]/10 bg-white/60 p-4">
                <dt className="text-xs text-[#6f5a40]">{e.label}</dt>
                <dd className="relative mt-1 h-6 overflow-hidden text-base font-semibold">
                  <AnimatePresence initial={false}>
                    <motion.span key={e.song.id} className="absolute inset-0 truncate" initial={still ? false : { y: 18, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={still ? { opacity: 0 } : { y: -18, opacity: 0 }} transition={{ duration: 0.3 }}>
                      {e.song.title}
                    </motion.span>
                  </AnimatePresence>
                </dd>
              </div>
            ))}
          </dl>
        </div>

        <div className="rounded-[28px] bg-[#f3e6cf] p-3 sm:p-4">
          <div className="flex items-center justify-between px-2 pb-3 pt-1">
            <h3 className="text-sm font-semibold">Set one</h3>
            <p className="font-mono text-xs tabular-nums text-[#5c4a33]">
              {items.length} songs, {clock(total)}
            </p>
          </div>
          <Reorder.Group as="ol" axis="y" values={items} onReorder={setItems} className="flex flex-col gap-2">
            {items.map((song, i) => (
              <Row key={song.id} song={song} index={i} total={items.length} held={held === song.id} ghost={demo && held === song.id} still={still} onGrab={grab} onKey={onKey(song, i)} />
            ))}
          </Reorder.Group>
          <p className="px-2 pt-3 text-xs text-[#6f5a40]">Drag a handle, or focus one and press Space, then the arrow keys.</p>
          <p aria-live="polite" className="sr-only">
            {said}
          </p>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/drag-to-reorder-list-with-spring-lift-and-shadow.json

When to use it

How to customize it

Accessibility

Questions

How does the drag to reorder work?

It uses the `Reorder.Group` and `Reorder.Item` components from framer-motion. The group holds the array in state. While a row is dragged, the library compares its position with its neighbours and calls `onReorder` with a new array when it passes one, and every other row animates to its new slot with a layout animation.

What makes the grabbed row look lifted?

Three things change together on a spring: scale goes to 1.04, the row rotates by just over one degree, and the box shadow switches from a hairline to a large, soft, offset shadow with an orange ring. A big blurred shadow placed well below an object is what the eye reads as height.

Why drag by a handle and not the whole row?

On a touch screen a draggable row would capture the gesture people use to scroll the page. The handle has `touch-action: none` so only that small area starts a drag, and the rest of the row scrolls as usual.

How does the demo move rows without a pointer?

It sets a `held` id, which applies the same lifted style used for a real drag, then reorders the array in state. The layout animation carries the row to its new slot, and clearing `held` drops it. A held row is also raised a few pixels, which keeps it drawn above the rows it crosses.

Can the list scroll or hold more items?

Yes. Add entries to the array, and for a long list give the `<ol>` a fixed height with `overflow-y-auto` and pass `layoutScroll` to `Reorder.Group` so positions are measured correctly inside the scrolling box.

Related: Feature Request Voting Board with Live Reordering, Draggable Card Stack Carousel with Spring Physics, Sortable Data Table with Status Badges and Row Actions.

More tables and lists or all free UI components. Start for free to have a full site built around it.

More in Tables

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap