Skip to main content

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>
);
}
  • Enter or Space selects a card.
  • ArrowRight / ArrowLeft move the card between columns.
  • Escape deselects.
  • tabIndex={0} makes the card focusable.
  • role="button" tells screen readers it's interactive.
  • aria-pressed indicates 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

CriterionStatusNotes
1.3.1 Info and Relationships (Level A)PassSemantic HTML, ARIA landmarks
2.1.1 Keyboard (Level A)PassAll features accessible via keyboard
2.1.2 No Keyboard Trap (Level A)PassFocus can escape any element
2.4.3 Focus Order (Level A)PassLogical tab order via tabIndex
2.4.7 Focus Visible (Level AA)PassVisible focus indicator (CSS :focus)
3.2.4 Consistent Identification (Level AA)PassButtons, labels consistent across pages
4.1.2 Name, Role, Value (Level A)PassARIA 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.

Further Reading