A card disappears from a list. Was it deleted, moved or just hidden? All three actions can look exactly the same. This is where movement can help.

What interests me is what someone understands without having to stop and think. Movement should make it clear where the card went.

Following an action

In this example, a task moves from “To do” to “Done”. We can change its position instantly or show it moving. Drag the card to the right, then back to the left. The button also lets you compare both versions.

A card that changes columns
To doDone
PortfolioCheck the mobile version

The task is to do.

Drag the card with your mouse or finger. On a keyboard, use the left and right arrows. Nothing is saved. The card follows your gesture even when transitions are disabled.

Following your finger without lag

During the gesture, the card should stay under the pointer. Adding a transition to every update makes it feel like you are pulling against resistance. Here, transitions stop during dragging. They return on release to settle the card in the nearest column.

The position is a number between 0 and 1. We add the distance travelled, divided by the width of a column, then clamp it to that range. A threshold of 0.5 determines the destination column.

drag.ts
export function dragProgress(start: number, deltaX: number, distance: number) {
  if (distance <= 0) return start;
  return Math.max(0, Math.min(1, start + deltaX / distance));
}

export function dropColumn(progress: number): 0 | 1 {
  return progress >= 0.5 ? 1 : 0;
}
The functions used in this demo

Pointer Events handles mouse, pen and touch input through the same handlers. Pointer capture keeps the gesture active when the pointer leaves the card. A cancelled gesture returns the card to its column. The style touch-action: pan-y preserves vertical scrolling on phones.

See the full React component
MovementDemo.tsx
import { useLocale } from "../../lib/locale";
import { useRef, useState } from "react";
import type { PointerEvent } from "react";
import { ArrowRight, RotateCcw } from "lucide-react";
import { dragProgress, dropColumn } from "../../lib/drag";

export default function MovementDemo() {
  const { t } = useLocale();
  const [done, setDone] = useState(false);
  const [animated, setAnimated] = useState(true);
  const [keyboard, setKeyboard] = useState(false);
  const [progress, setProgress] = useState<number | null>(null);
  const gesture = useRef<{
    id: number;
    x: number;
    start: number;
    distance: number;
    progress: number;
  } | null>(null);

  function startDrag(event: PointerEvent<HTMLDivElement>) {
    if (!event.isPrimary || event.button !== 0 || gesture.current) return;
    const card = event.currentTarget;
    const track = card.parentElement;
    if (!track) return;
    // Pick up the card where it is, even during an interrupted transition.
    const distance = card.offsetWidth;
    const offset =
      card.getBoundingClientRect().left -
      track.getBoundingClientRect().left -
      card.offsetLeft;
    const start = Math.max(0, Math.min(1, offset / distance));
    gesture.current = {
      id: event.pointerId,
      x: event.clientX,
      start,
      distance,
      progress: start,
    };
    card.setPointerCapture(event.pointerId);
    setKeyboard(false);
    setProgress(start);
  }

  function moveDrag(event: PointerEvent<HTMLDivElement>) {
    const current = gesture.current;
    if (!current || current.id !== event.pointerId) return;
    current.progress = dragProgress(
      current.start,
      event.clientX - current.x,
      current.distance,
    );
    setProgress(current.progress);
  }

  function finishDrag(event: PointerEvent<HTMLDivElement>, cancelled = false) {
    const current = gesture.current;
    if (!current || current.id !== event.pointerId) return;
    gesture.current = null;
    if (!cancelled) {
      const end = dragProgress(
        current.start,
        event.clientX - current.x,
        current.distance,
      );
      setDone(dropColumn(end) === 1);
    }
    setProgress(null);
    if (event.currentTarget.hasPointerCapture(event.pointerId))
      event.currentTarget.releasePointerCapture(event.pointerId);
  }
  return (
    <figure className="article-demo">
      <div className="flex flex-wrap items-center justify-between gap-3 border-b border-line px-4 py-3 text-xs">
        <span className="text-muted">
          {t("Une carte qui change de colonne")}
        </span>
        <label className="flex cursor-pointer items-center gap-2 text-ink">
          <input
            type="checkbox"
            checked={animated}
            onChange={(event) => setAnimated(event.target.checked)}
            className="size-3.5 accent-ink"
          />
          {t("Avec mouvement")}
        </label>
      </div>
      <div className="p-4 sm:p-6">
        <div className="grid grid-cols-2 gap-3 text-[11px] text-muted">
          <span>{t("À faire")}</span>
          <span>{t("Terminé")}</span>
        </div>
        <div className="relative mt-3 h-28 rounded-lg bg-line/50 p-2">
          <div
            aria-hidden="true"
            className="absolute inset-y-0 left-1/2 border-l border-dashed border-muted/25"
          />
          <div
            className="demo-moving-card grid h-24 w-1/2 touch-pan-y select-none content-center gap-2 rounded-md border border-line bg-page px-3 shadow-sm"
            role="slider"
            tabIndex={0}
            aria-label={t("Déplacer la tâche")}
            aria-valuemin={0}
            aria-valuemax={1}
            aria-valuenow={done ? 1 : 0}
            aria-valuetext={done ? t("Terminé") : t("À faire")}
            aria-describedby="drag-instructions"
            onPointerDown={startDrag}
            onPointerMove={moveDrag}
            onPointerUp={(event) => finishDrag(event)}
            onPointerCancel={(event) => finishDrag(event, true)}
            onLostPointerCapture={(event) => finishDrag(event, true)}
            onKeyDown={(event) => {
              if (event.key === "Escape") {
                const current = gesture.current;
                gesture.current = null;
                setProgress(null);
                if (
                  current &&
                  event.currentTarget.hasPointerCapture(current.id)
                )
                  event.currentTarget.releasePointerCapture(current.id);
                return;
              }
              if (
                ![
                  "ArrowLeft",
                  "ArrowRight",
                  "Home",
                  "End",
                  " ",
                  "Enter",
                ].includes(event.key)
              )
                return;
              event.preventDefault();
              if (event.repeat) return;
              gesture.current = null;
              setProgress(null);
              setKeyboard(true);
              setDone(
                event.key === "ArrowLeft" || event.key === "Home"
                  ? false
                  : event.key === "ArrowRight" || event.key === "End"
                    ? true
                    : !done,
              );
            }}
            style={{
              cursor: progress === null ? "grab" : "grabbing",
              transform: `translateX(${(progress ?? (done ? 1 : 0)) * 100}%)`,
              transition:
                animated && !keyboard && progress === null
                  ? "transform 220ms cubic-bezier(0.22, 1, 0.36, 1)"
                  : "none",
            }}
          >
            <span className="text-[10px] text-muted">Portfolio</span>
            <span className="text-xs font-medium leading-relaxed text-ink">
              {t("Vérifier la version mobile")}
            </span>
          </div>
        </div>
        <div className="mt-4 flex flex-wrap items-center justify-between gap-3">
          <p role="status" className="text-xs text-muted">
            {done ? t("La tâche est terminée.") : t("La tâche est à faire.")}
          </p>
          <button
            type="button"
            onKeyDown={(event) => {
              if (event.key !== "Enter" && event.key !== " ") return;
              event.preventDefault();
              if (event.repeat) return;
              setKeyboard(true);
              setDone((value) => !value);
            }}
            onClick={(event) => {
              setKeyboard(event.detail === 0);
              setDone((value) => !value);
            }}
            disabled={progress !== null}
            className="inline-flex min-h-10 items-center gap-2 rounded-md border border-line bg-page px-3 text-xs text-ink hover:bg-surface active:scale-[0.98] disabled:opacity-50"
          >
            {done ? t("Remettre à faire") : t("Terminer la tâche")}
            {done ? (
              <RotateCcw className="size-3.5" aria-hidden="true" />
            ) : (
              <ArrowRight className="size-3.5" aria-hidden="true" />
            )}
          </button>
        </div>
      </div>
      <figcaption
        id="drag-instructions"
        className="border-t border-line px-4 py-3 text-[11px] leading-relaxed text-muted"
      >
        {t(
          "Glisse la carte à la souris ou au doigt. Au clavier, utilise les flèches gauche et droite. Rien n’est enregistré. Le déplacement suit ton geste, même si les transitions sont désactivées.",
        )}
      </figcaption>
    </figure>
  );
}
The component running on this page

Movement connects the starting point to the destination. The card keeps its shape and label. It does not need a rotation, bounce or colour change to explain what just happened.

Written confirmation matters just as much. If you are not watching the card when you click, or motion is disabled, “The task is done” still gives you the information you need.

Keep the user in control

Now try clicking several times in a row. The movement should be able to change direction. You should not have to wait for a little performance to end before the button works again.

In a real application, this is also about state. Moving a card on screen does not prove the server saved the change. If saving fails, say so and let the user retry. A smooth transition does not replace that work.

Test without motion

I find it useful to look at an interface with every transition turned off. Are the labels enough? Is the selection still visible? Does an important action have a confirmation?

If the answer is no, some information is probably missing. I would rather add it before working on the movement.

In this example, reduced motion preferences and keyboard actions produce an immediate change. The final state stays the same. That continuity is what I want to preserve.