Chapter 16
Drag and drop that works from the keyboard
This chapter builds a Kanban board, and uses it to work through the accessibility problem that drag-and-drop interfaces almost always get wrong.
Drag and drop is the most reliably inaccessible pattern on the web. The interaction is defined by a physical gesture, the affordance is usually a cursor change, and the state that matters lives in coordinates rather than in the DOM. Most implementations are unusable without a mouse, and the ones that are not tend to have been built that way from the start.
This chapter builds a Kanban board, which is the canonical case: cards that reorder within a column and move between columns. The board itself is a fine component. The reason it earns a chapter is that it forces every decision the pattern gets wrong.
Choosing a library
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
@dnd-kit is the right choice for one reason above the others: keyboard support is a first-class sensor, not an afterthought. Its architecture treats pointer, keyboard, and touch as interchangeable input sources feeding the same drag state.
The HTML5 drag-and-drop API, the platform’s own answer, is not a real option here. Its keyboard support is nonexistent, its touch support is inconsistent across browsers, and its styling hooks are famously limited.
react-beautiful-dnd was the previous default and had good keyboard support, but it is no longer maintained. @dnd-kit is the successor most projects moved to.
The API
export interface RudiKanbanItem {
id: string
[key: string]: unknown
}
export interface RudiKanbanColumnData {
id: string
title: string
items: RudiKanbanItem[]
}
export interface RudiKanbanCardMoveEvent {
cardId: string
fromColumnId: string
toColumnId: string
fromIndex: number
toIndex: number
}
export interface RudiKanbanBoardProps {
columns: RudiKanbanColumnData[]
onCardMove: (event: RudiKanbanCardMoveEvent) => void
renderCard: (item: RudiKanbanItem) => React.ReactNode
className?: string
}
The board is fully controlled. It never mutates columns; it reports a move and re-renders from whatever the consumer passes next.
That matters more here than for a typical controlled input. Card moves usually need to persist, and persistence fails. If the board owned its own state, a rejected server write would leave the UI showing a move that did not happen, and reconciling that means fighting the component. Controlled, the consumer applies the move optimistically, and reverts by passing the old array back.
RudiKanbanCardMoveEvent carries both the column identifiers and the indices because a consumer needs both. The column tells you what status to write; the index tells you where in the ordering it landed.
renderCard is a render prop rather than a card component, because card content is entirely the consumer’s business. The board owns drag behavior and layout; what a card shows is not its problem.
Sensors: where accessibility is decided
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
)
These lines decide whether the board works without a mouse.
KeyboardSensor makes the board operable without a pointer: Space or Enter picks a card up, arrow keys move it, Space or Enter drops it, Escape cancels. Omit this sensor and the board works perfectly with a mouse and cannot be used at all otherwise. Nothing warns you, because the mouse path is the one you test.
The keyboard entry point is not the card itself. KanbanCard spreads dnd-kit’s attributes and listeners onto a dedicated <button className="rudi-kanban__card-handle"> with its own aria-label, and that button is what receives focus. Putting them on the card <div> instead would turn the whole card into a role="button" wrapping other controls, and it would swallow Space and Enter from any control inside it.
sortableKeyboardCoordinates translates arrow presses into positions within the sortable collections. Without it the keyboard sensor moves the card by a fixed pixel offset, which is meaningless for a list-based layout: the card drifts across the screen without ever landing in a slot.
activationConstraint: { distance: 5 } requires five pixels of movement before a pointer drag begins, so a click or a small slip on the handle does not start one. If you ever put the listeners on the whole card, this constraint is also what keeps links and buttons inside it clickable.
Announcing what happened
A sighted user watching a card move needs no narration. A screen reader user needs the whole interaction described, because the visual change is the entire feedback channel.
DndContext ships default announcements and its own live region, which is more than most libraries give you. They are generic by necessity: dnd-kit knows a draggable moved over a droppable, not that a card moved into “In Progress”. Replacing them with domain-specific messages is the point of what follows.
const announcementRef = useRef<HTMLDivElement>(null)
const announce = useCallback((message: string) => {
if (announcementRef.current) {
announcementRef.current.textContent = message
}
}, [])
// …rendered at the end of the board:
<div
ref={announcementRef}
role="alert"
aria-atomic="true"
className="rudi-kanban__sr-only"
/>
The live region is written at four points:
// On pick up
announce(`Picked up card from ${col.title}`)
// While moving over a column
announce(`Over column ${overCol.title}`)
// On drop
announce(`Dropped card in ${toColumn.title} at position ${toIndex + 1}`)
// On drop with no droppable under the pointer
announce('Cancelled drag')
That last one is worth reading carefully, because its placement is a real gap. It lives in handleDragEnd, guarded by if (!over), so it fires when a card is released over empty space. It does not fire when the user presses Escape: dnd-kit routes a genuine cancel to onDragCancel, and the board does not implement that handler.
The consequence is that Escape mid-drag announces nothing and never clears activeId, so the drag overlay stays on screen. Add the handler:
const handleDragCancel = useCallback(() => {
setActiveId(null)
announce('Cancelled drag')
}, [announce])
and pass onDragCancel={handleDragCancel} to DndContext. This is exactly the class of bug that only shows up on the keyboard path, which is why the test below matters.
Several details here are easy to get wrong.
The element is always in the DOM, empty. Live regions announce changes to their content. Mounting a region and populating it in the same render usually announces nothing, because assistive technology never observed the change. Render it empty at mount and write text into it later.
role="alert", not role="status". Both are live regions; the difference is politeness, and each role carries its own implicitly. status is polite, alert is assertive. Assertive interrupts, which is normally rude, but during a drag it is correct: the announcement responds to a key the user just pressed, and a polite announcement queued behind other output arrives after they have already pressed the next arrow. Set the politeness by choosing the role rather than by adding aria-live on top of one, because a role="status" carrying aria-live="assertive" leaves the outcome to the screen reader.
aria-atomic="true" makes the whole message read on each change, rather than only the diff. Without it, “Over column In Progress” followed by “Over column Done” can announce as a fragment.
The messages name things, not coordinates. “Dropped card in Done at position 3” is the state the user cares about. “Moved to 420, 180” is not.
The drop announcement fires before onCardMove. Setting the text first means the announcement is not waiting on whatever the consumer’s handler does, which may be a network request.
Detecting the destination
The drag end handler has one piece of real logic, and it exists because a card can be dropped on two different kinds of thing:
const toColumnDirect = columns.find((col) => col.id === overIdStr)
const toColumnViaItem = findColumnOfItem(columns, overIdStr)
const toColumn = toColumnDirect || toColumnViaItem
if (!toColumn) return
let toIndex: number
if (toColumnDirect) {
// Dropped on the column itself — append to end
toIndex = toColumn.items.length
} else {
// Dropped on a specific card
toIndex = toColumn.items.findIndex((item) => item.id === overIdStr)
}
Dropping on empty space below the cards means “put it in this column,” and the only sensible index is the end. Dropping onto a specific card means “put it here,” and the index is that card’s position. Both are droppable targets, and the handler has to distinguish them because an empty column has no cards to drop onto and would otherwise reject every drag.
Then a guard that is worth keeping:
if (fromColumn.id === toColumn.id && fromIndex === toIndex) return
A card picked up and put back has not moved. Firing onCardMove anyway means a network request and an entry in an activity log for a change that did not happen, and users pick cards up and think better of it constantly.
collisionDetection={closestCorners} is the other choice worth naming. The default rectIntersection requires actual overlap, which makes narrow gaps between columns feel unresponsive. closestCorners picks the nearest target by corner distance, which is more forgiving for the large, loosely spaced regions a board is made of.
The drag overlay
<DragOverlay>
{activeItem ? (
<div className="rudi-kanban__card rudi-kanban__card--overlay">
{renderCard(activeItem)}
</div>
) : null}
</DragOverlay>
DragOverlay renders a copy of the dragged card in a fixed-position layer that follows the cursor, while the original stays in place at reduced opacity.
Without it, the dragged card is transformed inside its column, which means it is clipped by any ancestor with overflow: hidden and constrained by the column’s stacking context. Since a board is horizontally scrolling columns, that clipping is guaranteed. position: fixed takes the overlay out of the flow and escapes both.
Fixed positioning is defeated by an ancestor with a transform, filter, or perspective, which establishes a new containing block. If your board sits inside an animated panel, wrap the overlay in createPortal to move it to the document body. @dnd-kit supports that and does not do it by default.
Testing it
Test the keyboard path, because it is the one that regresses. A pointer drag in jsdom requires simulating a sequence of pointer events with real coordinates, and it tends to test your event simulation rather than the component. The keyboard path is a sequence of key presses:
await user.tab() // focus the first card
await user.keyboard('{ }') // pick up
await user.keyboard('{ArrowRight}') // move to next column
await user.keyboard('{ }') // drop
expect(onCardMove).toHaveBeenCalledWith(
expect.objectContaining({ toColumnId: 'in-progress' }),
)
Run this test in a real browser, through Vitest’s browser mode or a Storybook play function. sortableKeyboardCoordinates measures the columns to find the next one, and jsdom reports every element at zero size, so in the jsdom unit project the card never moves.
That single test covers the sensor configuration, the coordinate getter, the destination logic, and the move event. If someone later removes KeyboardSensor, it fails immediately rather than shipping.
Assert on the live region too, and assert on its content rather than its presence. Announcements are exactly the kind of thing that survives a refactor as dead code, because nothing visible breaks when they stop firing.
What you have now
A drag-and-drop board that is fully operable from the keyboard, announces its state, and reports moves rather than owning them.
The same requirements apply beyond boards. Any interface whose primary interaction is a gesture, a sortable list, a resizable panel, a range slider with two handles, needs the same three things: a keyboard path to the same outcome, a live region describing what changed, and a controlled API so the consumer can reject the change. Retrofitting those onto a working mouse implementation is substantially harder than building them in, which is why so few implementations have them.
One chapter left, and it is about what you do after the code is finished.
This chapter has been corrected since it was first published: see what changed. Found an error? Report it.