Build a three-column Kanban board (To Do / In Progress / Done) as a single React component. Each column shows its title, a live count, and its cards. Cards are draggable with the native HTML5 drag-and-drop API, and dragging a card onto another column moves it there — the source loses it, the target gains it, and both counts update.
The starter App.tsx renders the three columns with their initial cards and counts, but nothing moves yet. Wire up the drag-and-drop:
todo / doing / done arrays) in useState instead of the fixed const.onDragStart, record which card and which column it came from.onDragOver, call e.preventDefault() — without it the column is not a valid drop target and onDrop never fires. Add an over class to highlight it.onDrop, remove the card from its source array and append it to the target array. Counts read from each array's length, so they update on their own.Alt+ArrowLeft or Alt+ArrowRight. Restore focus to the moved card and announce Moved {card} to {column}. through the polite live region.Set up CI from To Do onto In Progress: To Do drops to 2, In Progress rises to 2, and the card appears at the bottom of In Progress.over highlight); it clears when you leave or drop.Write specs and press Alt+ArrowRight: it appends to In Progress, focus follows it, and assistive technology hears Moved Write specs to In Progress.onDragOver must preventDefault. It is the single most-missed step; skip it and drops silently do nothing.columns[id].length at render time; never keep a separate counter in sync.over and dragging states) is already in styles.css; focus on the state and the handlers.You'll hold the board as one object of arrays in useState, then let native drag-and-drop move a card from one array to another.
A Kanban board is three lists side by side. Picking a card up and dropping it on another column should remove it from where it was and add it where it landed, and each column's count should follow. The browser already gives us drag events (dragstart, dragover, drop) — the work is deciding what state changes on each one.
The whole board is a single value: an object with one array per column. A "move" is not a special drag primitive — it's a plain array edit. Remember which card started dragging and from where; when it drops on a column, setColumns to a new object where the source array has the card filtered out and the target array has it appended. Because the counts are just columns[id].length, they re-render on their own.
A common first try reaches into the state object and pushes the card across on drop:
function handleDrop(toCol) {
const card = cards[dragging.from].find((c) => c.id === dragging.id);
cards[dragging.from] = cards[dragging.from].filter((c) => c.id !== card.id);
cards[toCol].push(card); // mutating state in place
}
// column markup — note: no onDragOver
<div className="col" onDrop={() => handleDrop(col.id)}>...</div>
Two things sink it. First, without an onDragOver that calls e.preventDefault(), the column is not a valid drop target, so onDrop never fires at all — the card snaps back and nothing happens. Second, even once drop fires, mutating the existing cards object in place doesn't change its identity, so React never re-renders and the board looks frozen.
import { useState } from 'react';
import './styles.css';
type ColumnId = 'todo' | 'doing' | 'done';
type Card = { id: string; text: string };
const columns: { id: ColumnId; title: string }[] = [
{ id: 'todo', title: 'To Do' },
{ id: 'doing', title: 'In Progress' },
{ id: 'done', title: 'Done' },
];
const initialCards: Record<ColumnId, Card[]> = {
todo: [
{ id: 't1', text: 'Design API' },
{ id: 't2', text: 'Write specs' },
{ id: 't3', text: 'Set up CI' },
],
doing: [{ id: 'p1', text: 'Build dashboard UI' }],
done: [{ id: 'd1', text: 'Create repo' }],
};
export default function App() {
const [cards, setCards] = useState(initialCards);
const [dragging, setDragging] = useState<{ id: string; from: ColumnId } | null>(null);
const [overCol, setOverCol] = useState<ColumnId | null>(null);
const [announcement, setAnnouncement] = useState('');
function moveCard(id: string, from: ColumnId, toCol: ColumnId, restoreFocus = false) {
if (from === toCol) return;
const card = cards[from].find((item) => item.id === id);
if (!card) return;
setCards((prev) => {
return {
...prev,
[from]: prev[from].filter((c) => c.id !== id),
[toCol]: [...prev[toCol], card],
};
});
const title = columns.find((col) => col.id === toCol)?.title;
setAnnouncement(`Moved ${card.text} to ${title}.`);
if (restoreFocus) {
requestAnimationFrame(() =>
(document.querySelector(`[data-card="${id}"]`) as HTMLElement | null)?.focus(),
);
}
}
function handleDrop(toCol: ColumnId) {
setOverCol(null);
const drag = dragging;
setDragging(null);
if (drag) moveCard(drag.id, drag.from, toCol);
}
function handleKeyDown(event: React.KeyboardEvent, cardId: string, from: ColumnId) {
if (!event.altKey || !['ArrowLeft', 'ArrowRight'].includes(event.key)) return;
const index = columns.findIndex((col) => col.id === from);
const toIndex = index + (event.key === 'ArrowRight' ? 1 : -1);
if (!columns[toIndex]) return;
event.preventDefault();
moveCard(cardId, from, columns[toIndex].id, true);
}
return (
<main className="container">
<h1>Kanban Board</h1>
<p id="board-instructions" className="sr-only">
Drag cards between columns, or focus a card and press Alt plus Left or Right Arrow.
</p>
<div className="board" role="group" aria-label="Project board" aria-describedby="board-instructions">
{columns.map((col) => (
<div
key={col.id}
className={overCol === col.id ? 'col over' : 'col'}
onDragOver={(e) => {
e.preventDefault(); // REQUIRED so the column accepts a drop
setOverCol(col.id);
}}
onDragLeave={() => setOverCol((c) => (c === col.id ? null : c))}
onDrop={() => handleDrop(col.id)}
role="group"
aria-label={col.title}
>
<div className="col-head">
<span>{col.title}</span>
<span className="count">{cards[col.id].length}</span>
</div>
<div className="cards" role="list">
{cards[col.id].map((card) => (
<div
key={card.id}
className={dragging?.id === card.id ? 'card dragging' : 'card'}
draggable="true"
tabIndex={0}
role="listitem"
aria-keyshortcuts="Alt+ArrowLeft Alt+ArrowRight"
data-card={card.id}
aria-label={`${card.text}, ${col.title}`}
onKeyDown={(e) => handleKeyDown(e, card.id, col.id)}
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', card.id);
setDragging({ id: card.id, from: col.id });
}}
onDragEnd={() => { setDragging(null); setOverCol(null); }}
>
{card.text}
</div>
))}
</div>
</div>
))}
</div>
<p className="sr-only" aria-live="polite" aria-atomic="true">
{announcement}
</p>
</main>
);
}
The board state and transient gesture state stay separate. Both pointer and keyboard paths call moveCard; it builds a new object, updates the live message, and optionally restores focus after React commits the card in its new column. Counts remain derived from array length.
The move itself is two array operations wrapped in a new object. The diagram below shows the shape: filter the card out of its source array, spread it onto the end of the target array, keep every other column as-is with ...prev.
Set up CI in To Do: onDragStart sets dragging = { id: 't3', from: 'todo' }; that card gets the dragging class and fades.onDragOver fires on every move, calls preventDefault() (arming the drop) and sets overCol = 'doing', greening its border.onDrop runs handleDrop('doing'). from (todo) is not the target, so setCards returns a new object — todo without t3, doing as [Build dashboard UI, Set up CI]. To Do's count re-renders to 2, In Progress to 2.preventDefault on onDragOver — the browser's default is to reject drops, so without it onDrop never fires. It must run on dragover, not just drop.cards[from].push(card) keeps the same object reference, so React skips the re-render. Always build a new object and array.count field drifts out of sync. Derive it from columns[id].length at render time instead.splice the card into place rather than always appending.cards into localStorage on change so a refresh keeps the layout.The reducer owns the durable board transition while the component keeps temporary drag and focus state. Pointer and keyboard input both dispatch the same move action.
import { useReducer, useState } from 'react';
import './styles.css';
type ColumnId = 'todo' | 'doing' | 'done';
type Card = { id: string; text: string };
type Board = Record<ColumnId, Card[]>;
const columns: { id: ColumnId; title: string }[] = [
{ id: 'todo', title: 'To Do' }, { id: 'doing', title: 'In Progress' }, { id: 'done', title: 'Done' },
];
const initialCards: Board = {
todo: [{ id: 't1', text: 'Design API' }, { id: 't2', text: 'Write specs' }, { id: 't3', text: 'Set up CI' }],
doing: [{ id: 'p1', text: 'Build dashboard UI' }],
done: [{ id: 'd1', text: 'Create repo' }],
};
function reducer(board: Board, action: { id: string; from: ColumnId; to: ColumnId }): Board {
if (action.from === action.to) return board;
const card = board[action.from].find((item) => item.id === action.id);
if (!card) return board;
return {
...board,
[action.from]: board[action.from].filter((item) => item.id !== action.id),
[action.to]: [...board[action.to], card],
};
}
export default function App() {
const [cards, move] = useReducer(reducer, initialCards);
const [dragging, setDragging] = useState<{ id: string; from: ColumnId } | null>(null);
const [over, setOver] = useState<ColumnId | null>(null);
const [announcement, setAnnouncement] = useState('');
function moveCard(id: string, from: ColumnId, to: ColumnId, restore = false) {
if (from === to || !cards[from].some((card) => card.id === id)) return;
const text = cards[from].find((card) => card.id === id)!.text;
move({ id, from, to });
setAnnouncement(`Moved ${text} to ${columns.find((col) => col.id === to)!.title}.`);
if (restore) requestAnimationFrame(() => (document.querySelector(`[data-card="${id}"]`) as HTMLElement | null)?.focus());
}
function onKeyDown(event: React.KeyboardEvent, id: string, from: ColumnId) {
if (!event.altKey || !['ArrowLeft', 'ArrowRight'].includes(event.key)) return;
const next = columns.findIndex((col) => col.id === from) + (event.key === 'ArrowRight' ? 1 : -1);
if (!columns[next]) return;
event.preventDefault();
moveCard(id, from, columns[next].id, true);
}
return <main className="container">
<h1>Kanban Board</h1>
<p id="board-instructions" className="sr-only">Drag cards between columns, or focus a card and press Alt plus Left or Right Arrow.</p>
<div className="board" role="group" aria-label="Project board" aria-describedby="board-instructions">
{columns.map((col) => <div key={col.id} className={over === col.id ? 'col over' : 'col'}
onDragOver={(event) => { event.preventDefault(); setOver(col.id); }} onDragLeave={() => setOver(null)}
onDrop={() => { const active = dragging; setDragging(null); setOver(null); if (active) moveCard(active.id, active.from, col.id); }}
role="group" aria-label={col.title}>
<div className="col-head"><span>{col.title}</span><span className="count">{cards[col.id].length}</span></div>
<div className="cards" role="list">{cards[col.id].map((card) => <div key={card.id}
className={dragging?.id === card.id ? 'card dragging' : 'card'} draggable="true" tabIndex={0}
role="listitem" aria-keyshortcuts="Alt+ArrowLeft Alt+ArrowRight" data-card={card.id}
aria-label={`${card.text}, ${col.title}`} onKeyDown={(event) => onKeyDown(event, card.id, col.id)}
onDragStart={() => setDragging({ id: card.id, from: col.id })}
onDragEnd={() => { setDragging(null); setOver(null); }}>{card.text}</div>)}</div>
</div>)}
</div>
<p className="sr-only" aria-live="polite" aria-atomic="true">{announcement}</p>
</main>;
}This version hides immutable movement, gesture state, announcements, and focus restoration behind a custom hook. The view only connects the returned handlers to the supplied markup.
import { useState } from 'react';
import './styles.css';
type ColumnId = 'todo' | 'doing' | 'done';
type Card = { id: string; text: string };
const columns: { id: ColumnId; title: string }[] = [
{ id: 'todo', title: 'To Do' }, { id: 'doing', title: 'In Progress' }, { id: 'done', title: 'Done' },
];
const initialCards: Record<ColumnId, Card[]> = {
todo: [{ id: 't1', text: 'Design API' }, { id: 't2', text: 'Write specs' }, { id: 't3', text: 'Set up CI' }],
doing: [{ id: 'p1', text: 'Build dashboard UI' }], done: [{ id: 'd1', text: 'Create repo' }],
};
function useKanban() {
const [cards, setCards] = useState(initialCards);
const [dragging, setDragging] = useState<{ id: string; from: ColumnId } | null>(null);
const [over, setOver] = useState<ColumnId | null>(null);
const [announcement, setAnnouncement] = useState('');
function move(id: string, from: ColumnId, to: ColumnId, restore = false) {
if (from === to) return;
const card = cards[from].find((item) => item.id === id);
if (!card) return;
setCards((board) => ({ ...board, [from]: board[from].filter((item) => item.id !== id), [to]: [...board[to], card] }));
setAnnouncement(`Moved ${card.text} to ${columns.find((col) => col.id === to)!.title}.`);
if (restore) requestAnimationFrame(() => (document.querySelector(`[data-card="${id}"]`) as HTMLElement | null)?.focus());
}
return { cards, dragging, over, announcement, setDragging, setOver, move };
}
export default function App() {
const board = useKanban();
function key(event: React.KeyboardEvent, id: string, from: ColumnId) {
if (!event.altKey || !['ArrowLeft', 'ArrowRight'].includes(event.key)) return;
const next = columns.findIndex((col) => col.id === from) + (event.key === 'ArrowRight' ? 1 : -1);
if (!columns[next]) return;
event.preventDefault(); board.move(id, from, columns[next].id, true);
}
return <main className="container"><h1>Kanban Board</h1>
<p id="board-instructions" className="sr-only">Drag cards between columns, or focus a card and press Alt plus Left or Right Arrow.</p>
<div className="board" role="group" aria-label="Project board" aria-describedby="board-instructions">
{columns.map((col) => <div key={col.id} className={board.over === col.id ? 'col over' : 'col'} role="group" aria-label={col.title}
onDragOver={(event) => { event.preventDefault(); board.setOver(col.id); }} onDragLeave={() => board.setOver(null)}
onDrop={() => { const active = board.dragging; board.setDragging(null); board.setOver(null); if (active) board.move(active.id, active.from, col.id); }}>
<div className="col-head"><span>{col.title}</span><span className="count">{board.cards[col.id].length}</span></div>
<div className="cards" role="list">{board.cards[col.id].map((card) => <div key={card.id}
className={board.dragging?.id === card.id ? 'card dragging' : 'card'} draggable="true" tabIndex={0} role="listitem"
aria-keyshortcuts="Alt+ArrowLeft Alt+ArrowRight" data-card={card.id} aria-label={`${card.text}, ${col.title}`}
onKeyDown={(event) => key(event, card.id, col.id)} onDragStart={() => board.setDragging({ id: card.id, from: col.id })}
onDragEnd={() => { board.setDragging(null); board.setOver(null); }}>{card.text}</div>)}</div>
</div>)}
</div><p className="sr-only" aria-live="polite" aria-atomic="true">{board.announcement}</p>
</main>;
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Build a three-column Kanban board (To Do / In Progress / Done) as a single React component. Each column shows its title, a live count, and its cards. Cards are draggable with the native HTML5 drag-and-drop API, and dragging a card onto another column moves it there — the source loses it, the target gains it, and both counts update.
The starter App.tsx renders the three columns with their initial cards and counts, but nothing moves yet. Wire up the drag-and-drop:
todo / doing / done arrays) in useState instead of the fixed const.onDragStart, record which card and which column it came from.onDragOver, call e.preventDefault() — without it the column is not a valid drop target and onDrop never fires. Add an over class to highlight it.onDrop, remove the card from its source array and append it to the target array. Counts read from each array's length, so they update on their own.Alt+ArrowLeft or Alt+ArrowRight. Restore focus to the moved card and announce Moved {card} to {column}. through the polite live region.Set up CI from To Do onto In Progress: To Do drops to 2, In Progress rises to 2, and the card appears at the bottom of In Progress.over highlight); it clears when you leave or drop.Write specs and press Alt+ArrowRight: it appends to In Progress, focus follows it, and assistive technology hears Moved Write specs to In Progress.onDragOver must preventDefault. It is the single most-missed step; skip it and drops silently do nothing.columns[id].length at render time; never keep a separate counter in sync.over and dragging states) is already in styles.css; focus on the state and the handlers.You'll hold the board as one object of arrays in useState, then let native drag-and-drop move a card from one array to another.
A Kanban board is three lists side by side. Picking a card up and dropping it on another column should remove it from where it was and add it where it landed, and each column's count should follow. The browser already gives us drag events (dragstart, dragover, drop) — the work is deciding what state changes on each one.
The whole board is a single value: an object with one array per column. A "move" is not a special drag primitive — it's a plain array edit. Remember which card started dragging and from where; when it drops on a column, setColumns to a new object where the source array has the card filtered out and the target array has it appended. Because the counts are just columns[id].length, they re-render on their own.
A common first try reaches into the state object and pushes the card across on drop:
function handleDrop(toCol) {
const card = cards[dragging.from].find((c) => c.id === dragging.id);
cards[dragging.from] = cards[dragging.from].filter((c) => c.id !== card.id);
cards[toCol].push(card); // mutating state in place
}
// column markup — note: no onDragOver
<div className="col" onDrop={() => handleDrop(col.id)}>...</div>
Two things sink it. First, without an onDragOver that calls e.preventDefault(), the column is not a valid drop target, so onDrop never fires at all — the card snaps back and nothing happens. Second, even once drop fires, mutating the existing cards object in place doesn't change its identity, so React never re-renders and the board looks frozen.
import { useState } from 'react';
import './styles.css';
type ColumnId = 'todo' | 'doing' | 'done';
type Card = { id: string; text: string };
const columns: { id: ColumnId; title: string }[] = [
{ id: 'todo', title: 'To Do' },
{ id: 'doing', title: 'In Progress' },
{ id: 'done', title: 'Done' },
];
const initialCards: Record<ColumnId, Card[]> = {
todo: [
{ id: 't1', text: 'Design API' },
{ id: 't2', text: 'Write specs' },
{ id: 't3', text: 'Set up CI' },
],
doing: [{ id: 'p1', text: 'Build dashboard UI' }],
done: [{ id: 'd1', text: 'Create repo' }],
};
export default function App() {
const [cards, setCards] = useState(initialCards);
const [dragging, setDragging] = useState<{ id: string; from: ColumnId } | null>(null);
const [overCol, setOverCol] = useState<ColumnId | null>(null);
const [announcement, setAnnouncement] = useState('');
function moveCard(id: string, from: ColumnId, toCol: ColumnId, restoreFocus = false) {
if (from === toCol) return;
const card = cards[from].find((item) => item.id === id);
if (!card) return;
setCards((prev) => {
return {
...prev,
[from]: prev[from].filter((c) => c.id !== id),
[toCol]: [...prev[toCol], card],
};
});
const title = columns.find((col) => col.id === toCol)?.title;
setAnnouncement(`Moved ${card.text} to ${title}.`);
if (restoreFocus) {
requestAnimationFrame(() =>
(document.querySelector(`[data-card="${id}"]`) as HTMLElement | null)?.focus(),
);
}
}
function handleDrop(toCol: ColumnId) {
setOverCol(null);
const drag = dragging;
setDragging(null);
if (drag) moveCard(drag.id, drag.from, toCol);
}
function handleKeyDown(event: React.KeyboardEvent, cardId: string, from: ColumnId) {
if (!event.altKey || !['ArrowLeft', 'ArrowRight'].includes(event.key)) return;
const index = columns.findIndex((col) => col.id === from);
const toIndex = index + (event.key === 'ArrowRight' ? 1 : -1);
if (!columns[toIndex]) return;
event.preventDefault();
moveCard(cardId, from, columns[toIndex].id, true);
}
return (
<main className="container">
<h1>Kanban Board</h1>
<p id="board-instructions" className="sr-only">
Drag cards between columns, or focus a card and press Alt plus Left or Right Arrow.
</p>
<div className="board" role="group" aria-label="Project board" aria-describedby="board-instructions">
{columns.map((col) => (
<div
key={col.id}
className={overCol === col.id ? 'col over' : 'col'}
onDragOver={(e) => {
e.preventDefault(); // REQUIRED so the column accepts a drop
setOverCol(col.id);
}}
onDragLeave={() => setOverCol((c) => (c === col.id ? null : c))}
onDrop={() => handleDrop(col.id)}
role="group"
aria-label={col.title}
>
<div className="col-head">
<span>{col.title}</span>
<span className="count">{cards[col.id].length}</span>
</div>
<div className="cards" role="list">
{cards[col.id].map((card) => (
<div
key={card.id}
className={dragging?.id === card.id ? 'card dragging' : 'card'}
draggable="true"
tabIndex={0}
role="listitem"
aria-keyshortcuts="Alt+ArrowLeft Alt+ArrowRight"
data-card={card.id}
aria-label={`${card.text}, ${col.title}`}
onKeyDown={(e) => handleKeyDown(e, card.id, col.id)}
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', card.id);
setDragging({ id: card.id, from: col.id });
}}
onDragEnd={() => { setDragging(null); setOverCol(null); }}
>
{card.text}
</div>
))}
</div>
</div>
))}
</div>
<p className="sr-only" aria-live="polite" aria-atomic="true">
{announcement}
</p>
</main>
);
}
The board state and transient gesture state stay separate. Both pointer and keyboard paths call moveCard; it builds a new object, updates the live message, and optionally restores focus after React commits the card in its new column. Counts remain derived from array length.
The move itself is two array operations wrapped in a new object. The diagram below shows the shape: filter the card out of its source array, spread it onto the end of the target array, keep every other column as-is with ...prev.
Set up CI in To Do: onDragStart sets dragging = { id: 't3', from: 'todo' }; that card gets the dragging class and fades.onDragOver fires on every move, calls preventDefault() (arming the drop) and sets overCol = 'doing', greening its border.onDrop runs handleDrop('doing'). from (todo) is not the target, so setCards returns a new object — todo without t3, doing as [Build dashboard UI, Set up CI]. To Do's count re-renders to 2, In Progress to 2.preventDefault on onDragOver — the browser's default is to reject drops, so without it onDrop never fires. It must run on dragover, not just drop.cards[from].push(card) keeps the same object reference, so React skips the re-render. Always build a new object and array.count field drifts out of sync. Derive it from columns[id].length at render time instead.splice the card into place rather than always appending.cards into localStorage on change so a refresh keeps the layout.The reducer owns the durable board transition while the component keeps temporary drag and focus state. Pointer and keyboard input both dispatch the same move action.
import { useReducer, useState } from 'react';
import './styles.css';
type ColumnId = 'todo' | 'doing' | 'done';
type Card = { id: string; text: string };
type Board = Record<ColumnId, Card[]>;
const columns: { id: ColumnId; title: string }[] = [
{ id: 'todo', title: 'To Do' }, { id: 'doing', title: 'In Progress' }, { id: 'done', title: 'Done' },
];
const initialCards: Board = {
todo: [{ id: 't1', text: 'Design API' }, { id: 't2', text: 'Write specs' }, { id: 't3', text: 'Set up CI' }],
doing: [{ id: 'p1', text: 'Build dashboard UI' }],
done: [{ id: 'd1', text: 'Create repo' }],
};
function reducer(board: Board, action: { id: string; from: ColumnId; to: ColumnId }): Board {
if (action.from === action.to) return board;
const card = board[action.from].find((item) => item.id === action.id);
if (!card) return board;
return {
...board,
[action.from]: board[action.from].filter((item) => item.id !== action.id),
[action.to]: [...board[action.to], card],
};
}
export default function App() {
const [cards, move] = useReducer(reducer, initialCards);
const [dragging, setDragging] = useState<{ id: string; from: ColumnId } | null>(null);
const [over, setOver] = useState<ColumnId | null>(null);
const [announcement, setAnnouncement] = useState('');
function moveCard(id: string, from: ColumnId, to: ColumnId, restore = false) {
if (from === to || !cards[from].some((card) => card.id === id)) return;
const text = cards[from].find((card) => card.id === id)!.text;
move({ id, from, to });
setAnnouncement(`Moved ${text} to ${columns.find((col) => col.id === to)!.title}.`);
if (restore) requestAnimationFrame(() => (document.querySelector(`[data-card="${id}"]`) as HTMLElement | null)?.focus());
}
function onKeyDown(event: React.KeyboardEvent, id: string, from: ColumnId) {
if (!event.altKey || !['ArrowLeft', 'ArrowRight'].includes(event.key)) return;
const next = columns.findIndex((col) => col.id === from) + (event.key === 'ArrowRight' ? 1 : -1);
if (!columns[next]) return;
event.preventDefault();
moveCard(id, from, columns[next].id, true);
}
return <main className="container">
<h1>Kanban Board</h1>
<p id="board-instructions" className="sr-only">Drag cards between columns, or focus a card and press Alt plus Left or Right Arrow.</p>
<div className="board" role="group" aria-label="Project board" aria-describedby="board-instructions">
{columns.map((col) => <div key={col.id} className={over === col.id ? 'col over' : 'col'}
onDragOver={(event) => { event.preventDefault(); setOver(col.id); }} onDragLeave={() => setOver(null)}
onDrop={() => { const active = dragging; setDragging(null); setOver(null); if (active) moveCard(active.id, active.from, col.id); }}
role="group" aria-label={col.title}>
<div className="col-head"><span>{col.title}</span><span className="count">{cards[col.id].length}</span></div>
<div className="cards" role="list">{cards[col.id].map((card) => <div key={card.id}
className={dragging?.id === card.id ? 'card dragging' : 'card'} draggable="true" tabIndex={0}
role="listitem" aria-keyshortcuts="Alt+ArrowLeft Alt+ArrowRight" data-card={card.id}
aria-label={`${card.text}, ${col.title}`} onKeyDown={(event) => onKeyDown(event, card.id, col.id)}
onDragStart={() => setDragging({ id: card.id, from: col.id })}
onDragEnd={() => { setDragging(null); setOver(null); }}>{card.text}</div>)}</div>
</div>)}
</div>
<p className="sr-only" aria-live="polite" aria-atomic="true">{announcement}</p>
</main>;
}This version hides immutable movement, gesture state, announcements, and focus restoration behind a custom hook. The view only connects the returned handlers to the supplied markup.
import { useState } from 'react';
import './styles.css';
type ColumnId = 'todo' | 'doing' | 'done';
type Card = { id: string; text: string };
const columns: { id: ColumnId; title: string }[] = [
{ id: 'todo', title: 'To Do' }, { id: 'doing', title: 'In Progress' }, { id: 'done', title: 'Done' },
];
const initialCards: Record<ColumnId, Card[]> = {
todo: [{ id: 't1', text: 'Design API' }, { id: 't2', text: 'Write specs' }, { id: 't3', text: 'Set up CI' }],
doing: [{ id: 'p1', text: 'Build dashboard UI' }], done: [{ id: 'd1', text: 'Create repo' }],
};
function useKanban() {
const [cards, setCards] = useState(initialCards);
const [dragging, setDragging] = useState<{ id: string; from: ColumnId } | null>(null);
const [over, setOver] = useState<ColumnId | null>(null);
const [announcement, setAnnouncement] = useState('');
function move(id: string, from: ColumnId, to: ColumnId, restore = false) {
if (from === to) return;
const card = cards[from].find((item) => item.id === id);
if (!card) return;
setCards((board) => ({ ...board, [from]: board[from].filter((item) => item.id !== id), [to]: [...board[to], card] }));
setAnnouncement(`Moved ${card.text} to ${columns.find((col) => col.id === to)!.title}.`);
if (restore) requestAnimationFrame(() => (document.querySelector(`[data-card="${id}"]`) as HTMLElement | null)?.focus());
}
return { cards, dragging, over, announcement, setDragging, setOver, move };
}
export default function App() {
const board = useKanban();
function key(event: React.KeyboardEvent, id: string, from: ColumnId) {
if (!event.altKey || !['ArrowLeft', 'ArrowRight'].includes(event.key)) return;
const next = columns.findIndex((col) => col.id === from) + (event.key === 'ArrowRight' ? 1 : -1);
if (!columns[next]) return;
event.preventDefault(); board.move(id, from, columns[next].id, true);
}
return <main className="container"><h1>Kanban Board</h1>
<p id="board-instructions" className="sr-only">Drag cards between columns, or focus a card and press Alt plus Left or Right Arrow.</p>
<div className="board" role="group" aria-label="Project board" aria-describedby="board-instructions">
{columns.map((col) => <div key={col.id} className={board.over === col.id ? 'col over' : 'col'} role="group" aria-label={col.title}
onDragOver={(event) => { event.preventDefault(); board.setOver(col.id); }} onDragLeave={() => board.setOver(null)}
onDrop={() => { const active = board.dragging; board.setDragging(null); board.setOver(null); if (active) board.move(active.id, active.from, col.id); }}>
<div className="col-head"><span>{col.title}</span><span className="count">{board.cards[col.id].length}</span></div>
<div className="cards" role="list">{board.cards[col.id].map((card) => <div key={card.id}
className={board.dragging?.id === card.id ? 'card dragging' : 'card'} draggable="true" tabIndex={0} role="listitem"
aria-keyshortcuts="Alt+ArrowLeft Alt+ArrowRight" data-card={card.id} aria-label={`${card.text}, ${col.title}`}
onKeyDown={(event) => key(event, card.id, col.id)} onDragStart={() => board.setDragging({ id: card.id, from: col.id })}
onDragEnd={() => { board.setDragging(null); board.setOver(null); }}>{card.text}</div>)}</div>
</div>)}
</div><p className="sr-only" aria-live="polite" aria-atomic="true">{board.announcement}</p>
</main>;
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.