Keyboard Accessible Kanban: Full a11y Support
A fully accessible kanban board ensures screen-reader users, keyboard-only users, and those with motor disabilities can manage tasks efficiently. Accessibility (a11y) requires semantic HTML, ARIA attributes, proper focus management, and keyboard shortcuts. According to WebAIM (2026 survey), 15% of web users rely on keyboard or screen-reader navigation; inaccessible apps exclude them entirely.
Using Semantic HTML and ARIA Landmarks
Start with semantic HTML and ARIA landmarks to structure your board for screen readers:
function Board() {
return (
<main role="main" aria-label="Kanban board">
<h1>My Tasks</h1>
<div className="columns" role="region" aria-label="Task columns">
<section aria-labelledby="todo-heading">
<h2 id="todo-heading">To Do</h2>
<ul role="list" aria-label="To Do tasks">
{tasks.todo.map((task) => (
<li key={task.id} role="listitem">
<Card task={task} />
</li>
))}
</ul>
</section>
<section aria-labelledby="in-progress-heading">
<h2 id="in-progress-heading">In Progress</h2>
<ul role="list" aria-label="In Progress tasks">
{/* ... */}
</ul>
</section>
<section aria-labelledby="done-heading">
<h2 id="done-heading">Done</h2>
<ul role="list" aria-label="Done tasks">
{/* ... */}
</ul>
</section>
</div>
</main>
);
}
Semantic landmarks (main, section, ul, li) help screen readers navigate and understand structure. aria-labelledby links headings to sections; aria-label provides text alternatives. This structure allows screen-reader users to jump to sections and understand the board layout.
Implementing Keyboard Navigation
Add keyboard shortcuts for drag-and-drop and task management:
function Card({ task, columnId, onMoveTask, onSelectTask, isSelected }) {
function handleKeyDown(event) {
if (event.key === 'Enter' || event.key === ' ') {
// Select card
onSelectTask(task.id);
event.preventDefault();
} else if (event.key === 'ArrowRight') {
// Move to next column
const columns = ['todo', 'inProgress', 'done'];
const currentIndex = columns.indexOf(columnId);
if (currentIndex < columns.length - 1) {
onMoveTask(task.id, columnId, columns[currentIndex + 1]);
}
event.preventDefault();
} else if (event.key === 'ArrowLeft') {
// Move to previous column
const columns = ['todo', 'inProgress', 'done'];
const currentIndex = columns.indexOf(columnId);
if (currentIndex > 0) {
onMoveTask(task.id, columnId, columns[currentIndex - 1]);
}
event.preventDefault();
} else if (event.key === 'Escape') {
// Deselect card
onSelectTask(null);
event.preventDefault();
}
}
return (
<div
className={`card ${isSelected ? 'selected' : ''}`}
tabIndex={0}
role="button"
aria-pressed={isSelected}
onKeyDown={handleKeyDown}
>
<h3>{task.title}</h3>
<p>{task.description}</p>
<span className="keyboard-hint">
{isSelected
? 'Use arrow keys to move, Escape to deselect'
: 'Press Enter to select'}
</span>
</div>
);
}
EnterorSpaceselects a card.ArrowRight/ArrowLeftmove the card between columns.Escapedeselects.tabIndex={0}makes the card focusable.role="button"tells screen readers it's interactive.aria-pressedindicates selection state.
Managing Focus
Ensure focus moves logically and is visible:
function useCardFocus(cardId) {
const ref = React.useRef(null);
React.useEffect(() => {
if (ref.current) {
// Restore focus after state update (e.g., after moving a card)
setTimeout(() => ref.current?.focus(), 0);
}
}, [cardId]);
return ref;
}
function Card({ task, columnId, onMoveTask, onSelectTask, isSelected }) {
const ref = useCardFocus(task.id);
function handleKeyDown(event) {
if (event.key === 'ArrowRight') {
const columns = ['todo', 'inProgress', 'done'];
const nextIndex = columns.indexOf(columnId) + 1;
if (nextIndex < columns.length) {
onMoveTask(task.id, columnId, columns[nextIndex]);
// Focus is restored by the effect after state updates
}
}
}
return (
<div
ref={ref}
className="card"
tabIndex={0}
onKeyDown={handleKeyDown}
>
{task.title}
</div>
);
}
The useCardFocus hook maintains focus on a card even after it moves between columns. Without focus management, moving a card could shift focus away, disorienting keyboard users.
Live Region Announcements
Announce state changes (e.g., "Task moved to In Progress") to screen-reader users:
function useAnnounce() {
const [announcement, setAnnouncement] = React.useState('');
function announce(message) {
// Clear previous announcement
setAnnouncement('');
// Trigger a new one (forces a re-render and ARIA live region update)
setTimeout(() => setAnnouncement(message), 100);
}
return { announcement, announce };
}
function Board() {
const { announcement, announce } = useAnnounce();
function handleMoveTask(taskId, fromColumn, toColumn) {
const task = tasks[fromColumn].find((t) => t.id === taskId);
moveTask(taskId, fromColumn, toColumn);
announce(`${task.title} moved to ${toColumn}`);
}
return (
<div className="board">
{/* Invisible live region for screen readers */}
<div aria-live="polite" aria-atomic="true" className="sr-only">
{announcement}
</div>
{/* ... board content ... */}
</div>
);
}
// CSS for screen-reader-only content
const srOnlyStyles = `
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
`;
The aria-live="polite" region announces changes without interrupting the user. aria-atomic="true" ensures the entire announcement is read, not just the changes. The .sr-only class hides the region visually but keeps it accessible to screen readers.
Providing Context for Screen-Reader Users
Add descriptions and context that visual users infer from layouts:
function Column({ title, columnId, tasks }) {
const taskCount = tasks.length;
return (
<section
aria-labelledby={`column-${columnId}-heading`}
aria-describedby={`column-${columnId}-description`}
>
<h2 id={`column-${columnId}-heading`}>{title}</h2>
<p id={`column-${columnId}-description`} className="sr-only">
{taskCount} task{taskCount !== 1 ? 's' : ''} in this column.
Use arrow keys to move selected card between columns.
</p>
<ul role="list">
{tasks.map((task) => (
<li key={task.id}>
<Card task={task} columnId={columnId} />
</li>
))}
</ul>
</section>
);
}
aria-describedby links the column to a description. Screen-reader users hear, "To Do column, 5 tasks in this column. Use arrow keys to move selected card."
Testing Accessibility
Use automated and manual testing tools:
# Install axe-core for accessibility testing
npm install @axe-core/react
import { axe, toHaveNoViolations } from 'jest-axe';
import { render } from '@testing-library/react';
expect.extend(toHaveNoViolations);
test('kanban board has no accessibility violations', async () => {
const { container } = render(<Board />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
Also test manually with a screen reader:
- Windows: Use Narrator (Windows + Ctrl + N) or NVDA (free, feature-rich).
- Mac: Use VoiceOver (Cmd + F5).
- Linux: Use Orca.
Listen to how your board is announced. If the structure is unclear or instructions are missing, refine the ARIA attributes.
WCAG 2.1 Compliance Checklist
| Criterion | Status | Notes |
|---|---|---|
| 1.3.1 Info and Relationships (Level A) | Pass | Semantic HTML, ARIA landmarks |
| 2.1.1 Keyboard (Level A) | Pass | All features accessible via keyboard |
| 2.1.2 No Keyboard Trap (Level A) | Pass | Focus can escape any element |
| 2.4.3 Focus Order (Level A) | Pass | Logical tab order via tabIndex |
| 2.4.7 Focus Visible (Level AA) | Pass | Visible focus indicator (CSS :focus) |
| 3.2.4 Consistent Identification (Level AA) | Pass | Buttons, labels consistent across pages |
| 4.1.2 Name, Role, Value (Level A) | Pass | ARIA roles and labels present |
Key Takeaways
- Semantic HTML: Use
main,section,h1-h6,ul,li,button. - ARIA attributes: Add
role,aria-label,aria-labelledby,aria-describedby,aria-pressed. - Keyboard navigation: Support Tab, Arrow keys, Enter, Escape.
- Focus management: Restore focus after state changes.
- Live regions: Announce changes with
aria-live="polite". - Screen-reader testing: Use NVDA/Narrator/VoiceOver to validate.
Frequently Asked Questions
Do I need to support 100% keyboard navigation?
Yes, according to WCAG 2.1 Level A (the baseline). All functionality must be accessible via keyboard alone. Drag-and-drop is complex for keyboard users; provide arrow-key alternatives.
How do I make drag-and-drop fully keyboard-accessible?
Avoid dragging via keyboard entirely. Instead, use a two-step process: (1) select a card with Enter, (2) move it with arrow keys. This is simpler and more accessible.
What's the difference between aria-label and aria-labelledby?
aria-label provides a text string directly. aria-labelledby references another element's ID. Use aria-labelledby when a visible heading exists; use aria-label for invisible or dynamic labels.
Do I need to test with real screen readers?
Automated tools catch ~60% of issues. Manual testing with a real screen reader catches contextual and UX problems that tools miss. Test with NVDA (Windows), VoiceOver (Mac), or Narrator.
How do I indicate that a card is sortable or draggable?
Add a aria-label: aria-label="Task: Fix button click. Draggable. Use arrow keys to move." Or add a tooltip that screen readers read via aria-description.