Skip to content
oks-ui
Engineering

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 limit reached) 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.