Dependency-free drag-and-drop: inside the oks-ui Board
· 6 min read · oks-ui team
Most React Kanban boards reach for a drag-and-drop library the moment cards need to move between columns — and inherit that library's bundle size, its own accessibility gaps, and its own opinions about touch and keyboard support. Board does pointer, touch,and keyboard drag-and-drop with zero dependencies. Here's how the keyboard path in particular actually works, since that's the part libraries most often skip entirely.
The data model: flat items, not a tree per column
Board doesn't want your data pre-grouped into columns — it takes one flat items array and a getItemColumn function, and groups them at render time. That means moving a card between columns is just changing what getItemColumn would return for it, in your own state — not restructuring a nested object:
<Board
aria-label="Sprint board"
columns={[
{ id: "todo", title: "To do" },
{ id: "doing", title: "In progress", limit: 3 },
{ id: "done", title: "Done" },
]}
items={items}
getItemId={(item) => item.id}
getItemColumn={(item) => item.columnId}
renderCard={(item) => <Card>{item.title}</Card>}
onItemMove={({ item, to }) => {
setItems((prev) =>
prev.map((i) => (i.id === item.id ? { ...i, columnId: to.columnId } : i)),
);
}}
/>Every drag — pointer, touch, or keyboard — ends at the exact same onItemMove callback, with a { item, itemId, from, to } move descriptor. The interaction layer is different for each input method; the state update you write is not.
Pointer and touch: a ghost, an indicator, and edge auto-scroll
Dragging with a mouse renders a floating "ghost" copy of the card under the cursor and an insertion-line indicator showing exactly where it will land; dragging near the edge of the board auto-scrolls it. Touch reuses the same machinery behind a long-press, so a short tap still scrolls the page normally instead of starting a drag.
Keyboard: lift, move, drop — with the state spoken aloud
This is the path most drag-and-drop libraries don't implement at all, and it's not an afterthought here — it's driven by the same onItemMove callback as the mouse:
- Space or Enter on a focused card lifts it — the card gets
aria-pressed="true", so assistive tech announces the state change, not just a visual one. - Arrow keys move the lifted card one position at a time — left/right between columns, up/down within a column — skipping any column that's full (its
limitreached) or explicitly disabled. - Space / Enter again drops the card in its new position, firing
onItemMove. - Escape cancels and returns the card to where it started — no move fires.
Every one of those steps is also spoken to screen readers through a dedicated live region, so a keyboard-only user gets the same "moved to In Progress, position 2 of 3" feedback a sighted mouse user gets by watching the insertion indicator:
// Inside Board, simplified: a visually-hidden status region
// updated on every lift / move / drop / cancel.
<div role="status" aria-live="polite" className="oksBoardSrStatus">
{announcement}
</div>Why this is worth doing without a dependency
A generic drag-and-drop library has to support arbitrary layouts — lists, grids, trees, freeform canvases — which means its accessibility story, if it has one, is necessarily generic too. Board only has to support one shape: cards in columns. That narrower scope is what makes it possible to hand-write the keyboard path completely, rather than bolting it onto a mouse-first library as an afterthought — and it means zero added weight in your bundle for something every other component in the library already avoids.
Full prop reference at the Board component page.