Une carte disparaît d’une liste. Est-ce qu’elle a été supprimée, déplacée ou simplement masquée ? Visuellement, ces trois actions peuvent produire exactement le même résultat. C’est là qu’un mouvement peut servir à quelque chose.
Le détail intéressant, pour moi, c’est ce que la personne comprend sans avoir à s’arrêter. Le mouvement doit aider à comprendre où la carte est passée.
Garder le fil d’une action
Dans cet exemple, une tâche passe de « À faire » à « Terminé ». On peut changer son emplacement immédiatement ou montrer son déplacement. Glisse la carte à droite, puis ramène-la à gauche. Le bouton permet aussi de comparer les deux versions.
La tâche est à faire.
Suivre le doigt, sans retard
Pendant le geste, la carte doit rester sous le pointeur. Ajouter une transition à chaque déplacement donne l’impression de tirer un objet qui résiste. Ici, la transition est coupée pendant le glissement. Elle revient au relâchement pour rejoindre la colonne la plus proche.
La position est un nombre entre 0 et 1. On ajoute la distance parcourue, divisée par la largeur d’une colonne, puis on bloque les dépassements. Le seuil de 0,5 choisit la colonne d’arrivée.
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;
}
Pointer Events permet de traiter la souris, le stylet et le tactile dans les mêmes gestionnaires. La capture du pointeur garde le geste actif quand on sort de la carte. Un geste annulé remet la carte dans sa colonne. Le style touch-action: pan-y conserve le défilement vertical sur téléphone.
Voir le composant React complet
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>
);
}
Le déplacement relie le point de départ au point d’arrivée. La carte garde sa forme et son intitulé. Il n’y a pas besoin de lui ajouter une rotation, un rebond ou un changement de couleur pour comprendre ce qui vient de se passer.
La confirmation écrite compte tout autant. Si l’on ne regarde pas la carte au moment du clic, ou si le mouvement est désactivé, « La tâche est terminée » donne toujours la bonne information.
Laisser la main
Essaie maintenant de cliquer plusieurs fois de suite. Le mouvement doit pouvoir changer de direction. Il ne devrait pas obliger à attendre la fin d’une petite mise en scène avant de rendre le bouton disponible.
Dans une vraie application, c’est aussi une question d’état. Déplacer la carte à l’écran ne prouve pas que le serveur a enregistré le changement. Si la sauvegarde échoue, il faut le dire et permettre de réessayer. Une transition fluide ne remplace pas ce travail.
Tester sans mouvement
Je trouve utile de regarder une interface avec toutes ses transitions coupées. Est-ce que les libellés suffisent ? Est-ce que la sélection se voit encore ? Est-ce qu’une action importante a une confirmation ?
Si la réponse est non, il manque probablement une information. Je préfère la remettre avant de travailler le mouvement.
Dans l’exemple, la préférence de réduction des animations et les actions au clavier donnent un changement immédiat. L’état final reste identique. C’est cette continuité que je cherche à garder.