Drag to Reorder Checklist with Slide Out Delete

A dark checklist panel where every row can be moved, marked and removed. Drag a row by its grip handle and the others slide out of the way. Tick the box on a row and the row gives up a little width, its text turns red, and a delete button springs into the gap on the right. Up and down arrows on each row do the same job as dragging for keyboard and switch users. Rows can be added at the bottom and the list can be reset. It is a single narrow column, so the phone layout is the same as the desktop one.

Code

// Adapted from Cult UI (https://www.cult-ui.com/docs/components/sortable-list)
// Copyright (c) 2023 Jordan-Gilliam. MIT License: https://we.inc/components/licenses
import { useState } from "react";
import { AnimatePresence, LayoutGroup, Reorder, motion, useDragControls, useReducedMotion } from "framer-motion";
import { ArrowDown, ArrowUp, Check, GripVertical, Plus, RotateCcw, Trash2 } from "lucide-react";

type Step = { id: number; text: string; note: string; checked: boolean };

const starting: Step[] = [
  { id: 1, text: "Dial in the espresso", note: "18 g in, 36 g out, about 28 seconds", checked: false },
  { id: 2, text: "Bake off the morning trays", note: "Croissants first, then the buns", checked: false },
  { id: 3, text: "Wipe down the pastry case", note: "Glass inside and out", checked: true },
  { id: 4, text: "Count the float", note: "$150 in the till", checked: false },
  { id: 5, text: "Chalk up today's filter", note: "Ask Noor which roast is on", checked: false },
];

const extras = ["Refill the oat milk", "Put the bench outside", "Check the grinder hopper", "Restock takeaway lids"];

const tile =
  "shadow-[0_1px_0_0_rgba(255,255,255,0.04)_inset,0_0_0_1px_rgba(255,255,255,0.05)_inset,0_0_0_1px_rgba(0,0,0,0.2),0_2px_2px_rgba(0,0,0,0.15),0_6px_10px_rgba(0,0,0,0.15)]";

function Row({ step, index, total, onToggle, onRemove, onMove }: { step: Step; index: number; total: number; onToggle: () => void; onRemove: () => void; onMove: (dir: number) => void }) {
  const reduce = useReducedMotion();
  const controls = useDragControls();

  return (
    <Reorder.Item
      value={step}
      dragListener={false}
      dragControls={controls}
      layout={reduce ? false : true}
      initial={reduce ? false : { opacity: 0, y: 12 }}
      animate={{ opacity: 1, y: 0 }}
      exit={reduce ? { opacity: 0 } : { opacity: 0, x: 40, filter: "blur(6px)", transition: { duration: 0.2 } }}
      transition={{ type: "spring", bounce: 0, duration: 0.4 }}
      whileDrag={{ scale: 1.02, zIndex: 20 }}
      className="relative flex items-center"
    >
      {/* The tile gives up some width when checked, and the delete button springs into the gap */}
      <motion.div layout={reduce ? false : true} transition={{ type: "spring", bounce: 0.15, duration: 0.45 }} className={`flex min-w-0 items-center gap-1 rounded-xl bg-neutral-900 py-2 pl-1 pr-2 ${tile} ${step.checked ? "w-[calc(100%-4rem)]" : "w-full"}`}>
        <button
          type="button"
          aria-label={`Drag to reorder: ${step.text}`}
          onPointerDown={(e) => controls.start(e)}
          className="grid h-10 w-8 shrink-0 cursor-grab place-items-center rounded-lg text-neutral-500 outline-none hover:text-neutral-200 focus-visible:ring-2 focus-visible:ring-amber-300 active:cursor-grabbing"
          style={{ touchAction: "none" }}
        >
          <GripVertical className="h-4 w-4" aria-hidden="true" />
        </button>
        <button
          type="button"
          role="checkbox"
          aria-checked={step.checked}
          aria-label={`Mark for removal: ${step.text}`}
          onClick={onToggle}
          className="grid h-10 w-10 shrink-0 place-items-center rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-amber-300"
        >
          <span className={`grid h-5 w-5 place-items-center rounded-md border transition-colors ${step.checked ? "border-red-400 bg-red-400 text-neutral-950" : "border-white/25 bg-black/30"}`}>
            {step.checked && <Check className="h-3.5 w-3.5" strokeWidth={3} aria-hidden="true" />}
          </span>
        </button>
        <span className="hidden w-5 shrink-0 font-mono text-xs tabular-nums text-neutral-500 sm:block">{index + 1}</span>
        <motion.div
          key={String(step.checked)}
          initial={reduce ? false : { opacity: 0, filter: "blur(4px)" }}
          animate={{ opacity: 1, filter: "blur(0px)" }}
          transition={{ type: "spring", bounce: 0.2, delay: step.checked ? 0.15 : 0 }}
          className="min-w-0 flex-1 pl-1"
        >
          <p className={`text-[15px] font-medium leading-snug tracking-tight sm:truncate ${step.checked ? "text-red-300 line-through decoration-red-400/50" : "text-neutral-100"}`}>{step.text}</p>
          <p className="truncate text-xs text-neutral-400">{step.checked ? "Marked for removal" : step.note}</p>
        </motion.div>
        <div className="flex shrink-0">
          <button type="button" aria-label={`Move up: ${step.text}`} disabled={index === 0} onClick={() => onMove(-1)} className="grid h-10 w-8 place-items-center rounded-lg text-neutral-400 outline-none hover:text-white focus-visible:ring-2 focus-visible:ring-amber-300 disabled:opacity-30">
            <ArrowUp className="h-4 w-4" aria-hidden="true" />
          </button>
          <button type="button" aria-label={`Move down: ${step.text}`} disabled={index === total - 1} onClick={() => onMove(1)} className="grid h-10 w-8 place-items-center rounded-lg text-neutral-400 outline-none hover:text-white focus-visible:ring-2 focus-visible:ring-amber-300 disabled:opacity-30">
            <ArrowDown className="h-4 w-4" aria-hidden="true" />
          </button>
        </div>
      </motion.div>

      <AnimatePresence mode="popLayout">
        {step.checked && (
          <motion.div
            key="join"
            aria-hidden="true"
            initial={{ opacity: 0, scaleX: 0 }}
            animate={{ opacity: 1, scaleX: 1, transition: { delay: 0.15, duration: 0.15 } }}
            exit={{ opacity: 0, transition: { duration: 0 } }}
            className="h-6 w-3 origin-left border-y border-white/5 bg-neutral-900"
          />
        )}
        {step.checked && (
          <motion.div
            key="delete"
            initial={reduce ? { opacity: 0 } : { opacity: 0, x: -14, filter: "blur(4px)" }}
            animate={{ opacity: 1, x: 0, filter: "blur(0px)", transition: { delay: 0.25, type: "spring", bounce: 0.6, duration: 0.5 } }}
            exit={{ opacity: 0, x: -10, transition: { duration: 0.1 } }}
            className={`rounded-l-md rounded-r-xl border-r-2 border-red-300/60 bg-neutral-900 ${tile}`}
          >
            <button type="button" aria-label={`Delete: ${step.text}`} onClick={onRemove} className="grid h-12 w-[3.25rem] place-items-center rounded-r-xl outline-none focus-visible:ring-2 focus-visible:ring-red-300">
              <Trash2 className="h-4 w-4 text-red-400" aria-hidden="true" />
            </button>
          </motion.div>
        )}
      </AnimatePresence>
    </Reorder.Item>
  );
}

export default function DragToReorderChecklistWithSlideOutDelete() {
  const [steps, setSteps] = useState<Step[]>(starting);
  const [nextId, setNextId] = useState(starting.length + 1);

  const toggle = (id: number) => setSteps((list) => list.map((s) => (s.id === id ? { ...s, checked: !s.checked } : s)));
  const remove = (id: number) => setSteps((list) => list.filter((s) => s.id !== id));
  const move = (from: number, dir: number) =>
    setSteps((list) => {
      const to = from + dir;
      if (to < 0 || to >= list.length) return list;
      const next = [...list];
      [next[from], next[to]] = [next[to], next[from]];
      return next;
    });
  const add = () => {
    setSteps((list) => [...list, { id: nextId, text: extras[(nextId - starting.length - 1) % extras.length], note: "Added just now", checked: false }]);
    setNextId((n) => n + 1);
  };
  const marked = steps.filter((s) => s.checked).length;

  return (
    <section className="bg-stone-200 px-4 py-12 sm:px-6 sm:py-16">
      <div className="mx-auto max-w-md rounded-[28px] bg-neutral-950 p-4 shadow-2xl shadow-stone-900/30 sm:p-5">
        <div className="flex items-start justify-between gap-3 px-1">
          <div>
            <p className="text-xs font-medium uppercase tracking-widest text-amber-300">Corner Bakery &amp; Bar</p>
            <h2 className="mt-1 text-xl font-semibold tracking-tight text-white">Opening routine</h2>
          </div>
          <button
            type="button"
            onClick={() => {
              setSteps(starting);
              setNextId(starting.length + 1);
            }}
            className="inline-flex h-10 items-center gap-1.5 rounded-full px-3 text-xs font-medium text-neutral-300 outline-none hover:bg-white/10 hover:text-white focus-visible:ring-2 focus-visible:ring-amber-300"
          >
            <RotateCcw className="h-3.5 w-3.5" aria-hidden="true" /> Reset
          </button>
        </div>

        <LayoutGroup>
          <Reorder.Group axis="y" values={steps} onReorder={setSteps} className="mt-4 flex flex-col gap-2">
            <AnimatePresence initial={false}>
              {steps.map((step, index) => (
                <Row key={step.id} step={step} index={index} total={steps.length} onToggle={() => toggle(step.id)} onRemove={() => remove(step.id)} onMove={(dir) => move(index, dir)} />
              ))}
            </AnimatePresence>
          </Reorder.Group>

          <motion.div layout className="mt-3 flex items-center justify-between gap-3 px-1">
            <p role="status" className="text-xs text-neutral-400">
              {steps.length} steps{marked > 0 ? `, ${marked} marked for removal` : ""}
            </p>
            <button type="button" onClick={add} className="inline-flex h-10 items-center gap-1.5 rounded-full bg-amber-300 px-4 text-sm font-semibold text-neutral-950 outline-none hover:bg-amber-200 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950">
              <Plus className="h-4 w-4" aria-hidden="true" /> Add step
            </button>
          </motion.div>
        </LayoutGroup>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/drag-to-reorder-checklist-with-slide-out-delete.json

When to use it

How to customize it

Accessibility

Questions

What handles the drag and drop?

`Reorder.Group` and `Reorder.Item` from Framer Motion. The group receives the array and an `onReorder` callback, and each item is given its own value. No other drag library is needed.

Why can I only drag from the handle?

`dragListener={false}` turns off dragging on the whole row and `useDragControls` starts it from the grip button instead. That keeps the tick box and arrows easy to press and leaves page scrolling alone on touch screens.

How do I save the new order?

The `steps` state is always in display order. Send the array of ids to your server inside the function you pass to `onReorder`, and in `move` for the arrow buttons.

Why tick first and then delete?

It makes removal a two step action, which prevents accidents, and it lets someone mark several rows before removing them one by one.

Can ticked rows be dragged?

Yes. In this version a marked row can still be reordered. To lock it, skip the `controls.start` call when `step.checked` is true.

Adapted from Sortable List in Cult UI, used under its open-source licence. Licences and credits

Related: Feature Request Voting Board with Live Reordering, Sortable Data Table with Status Badges and Row Actions, Settings Panel with Toggle Switches and Save Bar.

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