Implémenter le drag and drop avec Claude Code
Drag and drop avec Claude Code: React, clavier, accessibilité, risques, tests et CTA.
Le drag and drop est une UI qui rate plus facilement qu’il n’y paraît.
Même si cela ressemble à « il suffit d’attraper une carte et de la déplacer », il faut en réalité penser à la souris, au tactile, au clavier, au lecteur d’écran, à la sauvegarde après réordonnancement, au dépôt dans une colonne vide et à l’annulation d’une fausse manœuvre. Si vous déléguez tout à Claude Code, un code qui « marche » sort vite, mais pour atteindre une qualité publiable, il est essentiel de cadrer les conditions dès le départ.
Dans cet article, nous construisons un tableau kanban en drag and drop avec React + TypeScript + dnd-kit. Pour les débutants, j’explique d’abord le sens de la source de glissement, de la cible de dépôt, des capteurs et de la mise à jour d’état, puis je rassemble les consignes à passer à Claude Code, le code d’implémentation, les vérifications d’accessibilité, les pièges et le tunnel de conversion.
À décider d’abord
Pour une implémentation de drag and drop, décidez en premier « ce qu’on déplace » et « où l’on sauvegarde ». Si ce point reste flou, Claude Code tend à générer du code qui réordonne par manipulation directe du DOM, ou des cartes non manipulables au clavier.
Pour un débutant, il suffit de retenir ces quatre mots.
| Terme | Sens simple | Où le voir dans le code |
|---|---|---|
| Source de glissement | L’élément qu’on attrape et déplace | La carte de tâche |
| Cible de dépôt | L’endroit où l’on peut poser | Colonne, avant ou après une carte |
| sensor | Le rôle qui détecte le mode de saisie | Souris, tactile, clavier |
| state | L’ordre correct affiché à l’écran | Le useState de React |
Comme schéma conceptuel, le flux est le suivant.
Action de l’utilisateur → le sensor détecte → DndContext la transforme en événement → mise à jour du state React → la colonne se redessine.
L’idée clé est de tenir le state pour vérité, plutôt que de réordonner directement le DOM. En React, plutôt que « faire bouger l’écran », pensez « l’écran change comme conséquence d’un changement d’ordre des données » : il y a moins de bugs.
Cas d’usage exploitables
Le drag and drop construit avec Claude Code ne doit pas s’arrêter à une simple démo : concevez jusqu’à son intégration dans des écrans métier. Voici les cas d’usage représentatifs.
| Cas d’usage | Exemple | Point d’attention |
|---|---|---|
| Réordonnancement de tâches | ToDo, liste d’apprentissage, ordre de publication d’articles | Décider du moment de sauvegarde après réordonnancement |
| Tableau kanban | Affaires commerciales, tickets de dev, candidats au recrutement | Ne pas oublier le dépôt dans une colonne vide |
| Téléversement de fichiers | Dépôt d’images, PDF, CSV | Valider la taille et le type MIME des fichiers |
| Édition de dashboard | Disposition des widgets et graphiques | Sur mobile, séparer le mode édition |
| Constructeur de formulaire | Réordonnancement des champs | Empêcher la suppression d’un champ obligatoire |
Le plus réutilisable est le tableau kanban. Comme il couvre le déplacement entre colonnes, le réordonnancement dans une même colonne, la colonne vide et le clavier, il s’applique aussi aux autres usages.
API native ou dnd-kit ?
Le navigateur dispose d’une fonctionnalité native décrite dans la HTML Drag and Drop API de MDN. Elle reste valable pour des usages comme déposer un fichier du bureau vers le navigateur. En revanche, pour le réordonnancement de listes React ou un kanban, le différentiel d’événements, le tactile et le clavier deviennent pénibles.
| Option | Situations adaptées | Points faibles |
|---|---|---|
| HTML Drag and Drop API | Dépôt de fichiers, échange de données avec une app externe | Contrôle difficile sur tactile et pour le réordonnancement fin |
| dnd-kit | Déplacement de cartes React, listes, kanban, opérations accessibles | Nécessite de comprendre les concepts de la bibliothèque |
L’exemple de cet article suit l’approche du Getting started de dnd-kit et du guide Accessibilité de dnd-kit, avec PointerSensor et KeyboardSensor. Réserver l’API native de MDN au téléversement de fichiers et dnd-kit au réordonnancement dans l’app est, en pratique, le partage le plus stable.
Les consignes à Claude Code
À Claude Code, ne dites pas « fais un drag and drop » : transmettez le mode de saisie, la sauvegarde, l’accessibilité et la méthode de vérification. Par exemple, en collant le JSON suivant comme description de tâche, on obtient un diff plus facile à relire.
{
"goal": "Build an accessible React drag-and-drop kanban board with TypeScript.",
"stack": ["React", "TypeScript", "@dnd-kit/core", "@dnd-kit/sortable"],
"requirements": [
"Support pointer and keyboard operation",
"Move cards within a column and across columns",
"Keep React state as the source of truth",
"Show visible focus styles",
"Do not persist data until drag end"
],
"verification": [
"Move a card with mouse or trackpad",
"Move a card with keyboard",
"Drop into an empty column",
"Cancel a drag with Escape"
]
}
Pour les bases, lire aussi le guide de développement React, l’accessibilité avec Claude Code et, côté tests, le guide de stratégie de tests aide à mieux comprendre.
Mise en place
On ajoute dnd-kit au template Vite React + TypeScript. Pour une intégration dans une app existante, les dépendances sont identiques.
npm create vite@latest dnd-demo -- --template react-ts
cd dnd-demo
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
npm run dev
Modèle de données
On crée src/taskModel.ts. Plutôt que de regrouper les tâches dans un seul tableau, tenir un tableau par colonne est plus facile à suivre pour un débutant.
export type ColumnId = "backlog" | "doing" | "done";
export type Task = {
id: string;
title: string;
note: string;
};
export type BoardState = Record<ColumnId, Task[]>;
export const columnOrder: ColumnId[] = ["backlog", "doing", "done"];
export const columnLabels: Record<ColumnId, string> = {
backlog: "Backlog",
doing: "Doing",
done: "Done",
};
export const initialBoard: BoardState = {
backlog: [
{ id: "task-1", title: "Write acceptance criteria", note: "Clarify done state before coding." },
{ id: "task-2", title: "Create upload fallback", note: "Keep file input for keyboard users." },
],
doing: [
{ id: "task-3", title: "Build sortable cards", note: "Use dnd-kit sensors and state updates." },
],
done: [
{ id: "task-4", title: "Check MDN guidance", note: "Confirm native drag events and file behavior." },
],
};
Composant React fonctionnel
On remplace src/App.tsx par le contenu suivant. DndContext gère l’ensemble du drag and drop, SortableContext le réordonnancement dans une colonne, useDroppable les emplacements de dépôt (y compris la colonne vide) et useSortable le déplacement de chaque carte.
import { useRef, useState, type CSSProperties } from "react";
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCorners,
useDroppable,
useSensor,
useSensors,
type DragEndEvent,
type DragOverEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import "./App.css";
import {
columnLabels,
columnOrder,
initialBoard,
type BoardState,
type ColumnId,
type Task,
} from "./taskModel";
function isColumnId(value: string): value is ColumnId {
return columnOrder.includes(value as ColumnId);
}
function findContainer(itemId: string, board: BoardState): ColumnId | null {
if (isColumnId(itemId)) {
return itemId;
}
return (
columnOrder.find((columnId) =>
board[columnId].some((task) => task.id === itemId),
) ?? null
);
}
export default function App() {
const [board, setBoard] = useState<BoardState>(initialBoard);
const [activeId, setActiveId] = useState<string | null>(null);
const sourceColumnRef = useRef<ColumnId | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 6 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
function handleDragStart(event: DragStartEvent) {
const taskId = String(event.active.id);
setActiveId(taskId);
sourceColumnRef.current = findContainer(taskId, board);
}
function handleDragOver(event: DragOverEvent) {
const { active, over } = event;
if (!over) {
return;
}
const activeTaskId = String(active.id);
const overId = String(over.id);
setBoard((currentBoard) => {
const activeColumn = findContainer(activeTaskId, currentBoard);
const overColumn = findContainer(overId, currentBoard);
if (!activeColumn || !overColumn || activeColumn === overColumn) {
return currentBoard;
}
const activeTask = currentBoard[activeColumn].find(
(task) => task.id === activeTaskId,
);
if (!activeTask) {
return currentBoard;
}
const nextActiveItems = currentBoard[activeColumn].filter(
(task) => task.id !== activeTaskId,
);
const overItems = currentBoard[overColumn];
const overIndex = overItems.findIndex((task) => task.id === overId);
const insertIndex = overIndex >= 0 ? overIndex : overItems.length;
return {
...currentBoard,
[activeColumn]: nextActiveItems,
[overColumn]: [
...overItems.slice(0, insertIndex),
activeTask,
...overItems.slice(insertIndex),
],
};
});
}
function handleDragEnd(event: DragEndEvent) {
const startedIn = sourceColumnRef.current;
sourceColumnRef.current = null;
setActiveId(null);
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
const activeTaskId = String(active.id);
const overId = String(over.id);
setBoard((currentBoard) => {
const activeColumn = findContainer(activeTaskId, currentBoard);
const overColumn = findContainer(overId, currentBoard);
if (!activeColumn || !overColumn || activeColumn !== overColumn) {
return currentBoard;
}
if (startedIn && startedIn !== activeColumn) {
return currentBoard;
}
const columnTasks = currentBoard[activeColumn];
const oldIndex = columnTasks.findIndex((task) => task.id === activeTaskId);
const newIndex = columnTasks.findIndex((task) => task.id === overId);
if (oldIndex < 0 || newIndex < 0) {
return currentBoard;
}
return {
...currentBoard,
[activeColumn]: arrayMove(columnTasks, oldIndex, newIndex),
};
});
}
function handleDragCancel() {
sourceColumnRef.current = null;
setActiveId(null);
}
return (
<main className="appShell">
<header className="appHeader">
<div>
<p className="eyebrow">Claude Code demo</p>
<h1>Accessible drag-and-drop board</h1>
</div>
<p id="dnd-help" className="helpText">
Use the handle on each card. Keyboard: Tab to a handle, Space or Enter to lift,
arrow keys to move, Space or Enter to drop, Escape to cancel.
</p>
</header>
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div className="board" aria-describedby="dnd-help">
{columnOrder.map((columnId) => (
<KanbanColumn
key={columnId}
columnId={columnId}
tasks={board[columnId]}
activeId={activeId}
/>
))}
</div>
</DndContext>
</main>
);
}
function KanbanColumn({
columnId,
tasks,
activeId,
}: {
columnId: ColumnId;
tasks: Task[];
activeId: string | null;
}) {
const { setNodeRef, isOver } = useDroppable({ id: columnId });
return (
<section
ref={setNodeRef}
className={`column ${isOver ? "columnOver" : ""}`}
aria-labelledby={`heading-${columnId}`}
>
<h2 id={`heading-${columnId}`}>{columnLabels[columnId]}</h2>
<SortableContext
items={tasks.map((task) => task.id)}
strategy={verticalListSortingStrategy}
>
<div className="taskList">
{tasks.map((task) => (
<SortableTask key={task.id} task={task} isActive={activeId === task.id} />
))}
{tasks.length === 0 ? <p className="emptyState">Drop a task here</p> : null}
</div>
</SortableContext>
</section>
);
}
function SortableTask({ task, isActive }: { task: Task; isActive: boolean }) {
const {
attributes,
listeners,
setActivatorNodeRef,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: task.id });
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<article
ref={setNodeRef}
style={style}
className={`taskCard ${isDragging || isActive ? "taskCardDragging" : ""}`}
>
<button
ref={setActivatorNodeRef}
type="button"
className="dragHandle"
aria-label={`Move ${task.title}`}
{...attributes}
{...listeners}
>
<span aria-hidden="true">↕</span>
</button>
<div>
<h3>{task.title}</h3>
<p>{task.note}</p>
</div>
</article>
);
}
CSS
On remplace aussi src/App.css. En particulier, focus-visible et prefers-reduced-motion ne touchent pas qu’à l’apparence : ce sont des points examinés en revue d’accessibilité.
:root {
color: #172033;
background: #f6f7fb;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
body {
margin: 0;
}
button {
font: inherit;
}
.appShell {
min-height: 100vh;
padding: 32px;
}
.appHeader {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 460px);
gap: 24px;
align-items: end;
margin-bottom: 24px;
}
.eyebrow {
margin: 0 0 8px;
color: #4f46e5;
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
}
h1,
h2,
h3,
p {
margin-top: 0;
}
h1 {
margin-bottom: 0;
font-size: clamp(2rem, 4vw, 3.5rem);
}
.helpText {
margin-bottom: 0;
color: #526070;
line-height: 1.6;
}
.board {
display: grid;
grid-template-columns: repeat(3, minmax(220px, 1fr));
gap: 16px;
}
.column {
min-height: 420px;
padding: 16px;
border: 1px solid #d9deea;
border-radius: 8px;
background: #ffffff;
}
.columnOver {
outline: 3px solid #93c5fd;
outline-offset: 2px;
}
.taskList {
display: grid;
gap: 12px;
min-height: 120px;
}
.taskCard {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
gap: 12px;
align-items: start;
padding: 14px;
border: 1px solid #d9deea;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 8px 24px rgba(23, 32, 51, 0.08);
}
.taskCardDragging {
opacity: 0.58;
}
.taskCard h3 {
margin-bottom: 4px;
font-size: 1rem;
}
.taskCard p {
margin-bottom: 0;
color: #526070;
line-height: 1.5;
}
.dragHandle {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border: 1px solid #c8d0df;
border-radius: 8px;
background: #f8fafc;
color: #263244;
cursor: grab;
}
.dragHandle:active {
cursor: grabbing;
}
.dragHandle:focus-visible {
outline: 3px solid #2563eb;
outline-offset: 2px;
}
.emptyState {
display: grid;
min-height: 88px;
place-items: center;
border: 1px dashed #b9c2d3;
border-radius: 8px;
color: #6b7280;
}
@media (max-width: 760px) {
.appShell {
padding: 20px;
}
.appHeader,
.board {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
.taskCard {
transition: none;
}
}
Vérifier clavier et accessibilité
Avec le drag and drop, « ça marche à la souris » ne suffit pas. Avant publication, contrôlez les points suivants.
- On peut atteindre la poignée de chaque carte avec Tab.
- Space ou Enter permet de soulever la carte.
- Les flèches déplacent, Space ou Enter dépose.
- Escape annule l’opération.
- L’anneau de focus est visible.
- Le texte d’aide est relié à la zone d’opération par
aria-describedby. - On prend en compte
prefers-reduced-motionpour les utilisateurs sensibles au mouvement.
Pour le téléversement de fichiers, ne dépendez pas du seul glissement. Conservez toujours un <input type="file"> pour permettre la sélection au clavier. Pour les détails, voir l’implémentation du téléversement de fichiers et la HTML Drag and Drop API de MDN.
Pièges fréquents
Le premier est de sauvegarder côté serveur à chaque dragover ou onDragOver. Comme l’événement se déclenche de nombreuses fois pendant le glissement, la sauvegarde se fait en principe après confirmation du dépôt. Gérer l’intermédiaire avec le seul state React est plus stable.
Le deuxième est d’oublier de faire d’une colonne vide une cible de dépôt. Si l’on ne peut rien poser dans une colonne sans aucune carte, un kanban réel se bloque vite. L’exemple ajoute useDroppable({ id: columnId }) à la colonne pour éviter ce problème.
Le troisième est de faire une UI réservée à la souris. Sur tactile, un conflit avec le défilement apparaît, et l’utilisateur clavier ne peut pas opérer. Mettre à la fois PointerSensor et KeyboardSensor de dnd-kit et faire de la poignée un button est la solution réaliste.
Le quatrième est de lire l’ordre depuis le DOM. En React, c’est le state, et non le DOM, qui fait foi. Pour sauvegarder, concevez l’envoi du contenu de board à l’API.
Le cinquième est de donner des consignes trop larges à Claude Code. Plutôt que « rends l’UI sympa », passer des contraintes comme « utilise dnd-kit », « gère le clavier », « gère la colonne vide », « sauvegarde après drag end » stabilise la qualité du code généré. Pour la conception de prompts, le recueil de techniques de prompt est aussi utile.
L’intégrer au tunnel de conversion
Un article sur le drag and drop ne montre pas qu’une implémentation d’UI : c’est un sujet facile à relier à l’accompagnement Claude Code ou à la vente de templates. Le lecteur a une demande concrète : « je veux mettre cette UI dans mon app métier ».
Chez ClaudeCodeLab, nous proposons la formation et le conseil Claude Code pour les équipes, ainsi que des templates et supports pour standardiser les règles d’implémentation. Pour intégrer une UI comme un kanban, un téléversement de fichiers ou l’édition de dashboard dans un produit interne, écrire au préalable dans CLAUDE.md « accessibilité », « moment de sauvegarde » et « manipulation directe du DOM interdite » rend la sortie de Claude Code plus facile à relire. Pour cadrer CLAUDE.md, voir aussi les bonnes pratiques Claude.md.
Résultats obtenus en pratique
Note de vérification de Masa : avec ce montage, décider d’abord le BoardState par colonne avant de solliciter Claude Code a nettement réduit les corrections ultérieures. En particulier, inscrire dans une checklist le dépôt en colonne vide, le clavier et l’annulation par Escape permet de repérer tôt le code qui « marche visuellement mais n’est pas de qualité publiable ». Pour l’appliquer au dépôt de fichiers ou au réordonnancement d’images, décider d’abord la forme du state, le moment de sauvegarde et l’alternative clavier reste ce qui fonctionne le mieux.
Conclusion
Pour construire un drag and drop avec Claude Code, décidez avant la bibliothèque d’implémentation la structure de données, le mode de saisie, l’accessibilité et le moment de sauvegarde. Pour une UI de réordonnancement React, prenez dnd-kit comme repère ; pour la réception de fichiers, la HTML Drag and Drop API de MDN.
À partir de cet exemple, vous pouvez décliner vers la gestion de tâches, l’édition de l’ordre d’affichage des articles, la galerie d’images, le kanban commercial et l’édition de dashboard. Avant publication, vérifiez la souris, le clavier, la colonne vide, la largeur mobile et la façon de revenir en arrière en cas d’échec de sauvegarde.
PDF gratuit: cheatsheet Claude Code
Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.
Nous protégeons vos données et n'envoyons pas de spam.
À propos de l'auteur
Masa
Ingénieur spécialisé dans les workflows pratiques avec Claude Code.
Articles liés
Faire modifier un seul fichier à Claude Code : le brief qui évite les dégâts
Mon modèle de brief pour Claude Code : périmètre, vérification et retour arrière, né d'un « améliore ça » qui m'a changé 40 lignes.
Récupérer après un refus de permission Claude Code sans affaiblir les garde-fous
Transformer une commande refusée en plan sûr avec raison, alternative, preuves et critères de nouvel essai.
Claude Code Harness Smoke Test : boucle de preuve de 15 minutes avant de faire confiance à un agent
Un contrôle Claude Code pour cadrer portée, zones interdites, commandes de preuve, URL publique et CTA revenus.