Skip to content

Drag-and-drop in the oks-ui Board, tested in three browsers

Published · Updated · Last reviewed · by Omkar Sahu

A kanban board of three columns with a card lifted out and a dashed space where it came from.
ShareXLinkedIn
On this page
  1. The data model: flat items, not a tree per column
  2. Zero dependencies, checked
  3. The keyboard path
  4. What is announced, corrected
  5. Not previewed on screen
  6. Mouse and touch
  7. Gaps I found
  8. Text gets selected during a mouse drag
  9. Edge auto-scroll depends on the pointer moving
  10. Results
  11. Check your own board
  12. Why a narrow component can do this
  13. Limits
Updated 9 October 2026. I tested every claim in the first version of this post in Chromium, Firefox and WebKit, with oks-ui 1.4.1 and Playwright 1.63. The keyboard path, the shared callback and the zero-dependency claim all held. The announcements were described wrongly, and I found three gaps in Board that the first version did not mention. All of it is below, with a results table near the end.

Most React kanban boards reach for a drag-and-drop library the moment cards need to move between columns, and inherit that library's size and its own accessibility gaps. oks-ui's Board does mouse, touch and keyboard drag-and-drop with no dependencies. This post shows how each path works and what I saw when I tried to break them.

The data model: flat items, not a tree per column

Board does not want your data grouped into columns. It takes one flat items array and a getItemColumn function, and groups them when it renders. Moving a card between columns is then just a change to what getItemColumn returns for it, in your own state:

<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 ends at the same onItemMove callback, with an object holding item, itemId, from and to. I checked this by dragging with the mouse and moving with the keyboard on the same board: in all three browsers the callback fired once, with the same four keys, and from and to each holding a columnId and an index.

Zero dependencies, checked

The published oks-ui 1.4.1 package lists no dependencies, only react and react-dom as peers. I bundled oks-ui/board on its own with esbuild and looked at what it imports: only react and react/jsx-runtime. The Board code comes to 6.9 KB minified, 2.9 KB gzipped, and its stylesheet is 1.35 KB gzipped.

Advertisement

The keyboard path

This is the part most drag-and-drop code leaves out. These are the results from a real keyboard in all three browsers:

  • Space or Enter on a focused card lifts it. The card gets aria-pressed="true".
  • Arrow keys move the lifted card one place at a time: left and right between columns, up and down inside a column. Columns that are full (their limit reached) or have isDropDisabled are skipped. On my test board, lifting a card from "To do" and pressing the right arrow went straight to "Done", past a full column and a disabled one.
  • Space or Enter again drops it and fires onItemMove once. Focus stays on the card.
  • Escape cancels. No move fires, aria-pressed goes away and focus stays on the card.

What is announced, corrected

The first version of this post quoted "moved to In progress, position 2 of 3". That is not what Board says. Board has a visually hidden role="status" region with aria-live="polite", and these are the exact messages I recorded:

  • Lift: "Lifted card. To do, position 1 of 3. Use the arrow keys to move it, Space or Enter to drop, Escape to cancel."
  • Each arrow press: "Done, position 1 of 2"
  • Drop: "Card dropped."
  • Cancel: "Move cancelled."

Note what is missing: the drop message does not say where the card went. A screen-reader user hears the target on each arrow press, then only "Card dropped" at the end. I have not listened to this with a screen reader, so I cannot say how it sounds in practice; I only know what text is placed in the region.

Not previewed on screen

While a card is lifted with the keyboard, it does not move on screen and no insertion line appears. The only visible change is the focus ring on the card. The position exists in the announcements until you press Space. For a sighted keyboard user that is a real gap. I checked it in Chromium and WebKit.

The same board three times: a mouse drag with page text selected in blue, a mouse drag with one line of CSS added and no selection, and a keyboard-lifted card with a focus ring and the announced move.

Top: a mouse drag as shipped. Middle: the same drag with one line of CSS. Bottom: a card lifted with Space and moved right with the arrow key.

One more thing I noticed: pressing Tab while a card is lifted moves focus away, but the card stays lifted. Nothing cancels it, so a keyboard user can leave a board with a card still in the lifted state.

Mouse and touch

With the mouse, dragging shows a floating copy of the card under the pointer and an insertion line where it will land. Both appeared in all three browsers, and dropping fired the same callback as the keyboard. Dropping on a full column or a drop-disabled column did nothing, in all three. A click that moved only 2 pixels did not start a drag.

For touch, a card needs to be held for 180 ms before it can be dragged. I checked this with simulated touch events in all three browsers: a quick swipe did not drag, and a 400 ms hold followed by a move did. Simulated events are not real fingers, so I also sent real touch events to Chromium through the DevTools protocol. Holding and then dragging sideways moved a card across columns. Holding and then dragging straight down did not: the browser took the gesture over to scroll and cancelled the drag, so a card could not be reordered inside its column by touch. The cards use touch-action: pan-y, which lets the browser claim vertical gestures. I did not test Firefox or Safari with real touch events, and I did not use a physical phone.

Gaps I found

Text gets selected during a mouse drag

In all three browsers, dragging a card with the mouse selected the text of the other cards and the column headers, as the top screenshot shows. Board does not set user-select: none. Adding one rule fixed it in all three browsers:

.oksBoard[data-dragging="true"] {
  -webkit-user-select: none;
  user-select: none;
}

I tested this rule on my page. It relies on the data-dragging attribute that Board puts on its root while a drag is in progress. This is a gap in oks-ui 1.4.1 and I have noted it for the library.

Edge auto-scroll depends on the pointer moving

Dragging near the left or right edge of the board scrolls it sideways by 12 pixels for each pointer movement. In Chromium it kept scrolling when I held the pointer still at the edge. In Firefox and WebKit it stopped as soon as the pointer stopped, so the user has to keep wiggling the mouse. It scrolls horizontally only.

Results

Table of test results across Chromium, Firefox and WebKit for the keyboard, mouse, touch, auto-scroll, disabled and text selection checks.

Every result. "Not tested" means I did not run that case in that browser.

Check your own board

This script runs the main keyboard checks against a page with a Board, in each browser. It looks for the data-board-card attribute that Board puts on each card. Install playwright and its browsers first.

// node board-keyboard-check.mjs http://localhost:3000/your-board
import { chromium, firefox, webkit } from "playwright";

const url = process.argv[2];
const text = (page) => page.evaluate(() => document.querySelector('[role="status"]')?.textContent ?? "");

for (const [name, type] of [["Chromium", chromium], ["Firefox", firefox], ["WebKit", webkit]]) {
  const browser = await type.launch();
  const page = await browser.newPage();
  await page.goto(url);
  const card = page.locator("[data-board-card]").first();
  const id = await card.getAttribute("data-board-card");
  const checks = [];

  await card.focus();
  await page.keyboard.press("Space");
  checks.push(["Space lifts the card", (await card.getAttribute("aria-pressed")) === "true"]);
  checks.push(["lifting is announced", /^Lifted card/.test(await text(page))]);

  await page.keyboard.press("ArrowDown");
  checks.push(["an arrow key is announced", /position \d+ of \d+/.test(await text(page))]);

  await page.keyboard.press("Escape");
  checks.push(["Escape cancels", (await card.getAttribute("aria-pressed")) === null && (await text(page)) === "Move cancelled."]);
  checks.push(["focus stays on the card", await page.evaluate((i) => document.activeElement?.getAttribute("data-board-card") === i, id)]);

  console.log(`${name}: ${checks.map(([n, ok]) => `${ok ? "pass" : "FAIL"} ${n}`).join("; ")}`);
  await browser.close();
}

On my test board it printed five passes for each browser.

Why a narrow component can do this

A general drag-and-drop library has to handle lists, grids, trees and free-form canvases, so its keyboard and screen-reader support has to be general too. Board only has to handle cards in columns, and that narrower job is what made it possible to write the whole keyboard path by hand. The tests above are my evidence that it works. They are also my evidence for where it does not yet: the visible keyboard preview, text selection, touch reordering inside a column, and the drop announcement. See the full props on the Board component page.

Limits

  • I tested one board layout: four columns, one with a limit and one with drops disabled. Larger boards and nested scrolling were not tested.
  • I did not use a screen reader, so I know what text is announced but not how it sounds.
  • Real touch was tested in Chromium only, with emulated touch events, not a physical device. Safari on iOS was not tested.
  • Auto-scroll was measured by scroll position after pointer events, not by watching a person drag.
Advertisement

Omkar Sahu

I'm a front-end engineer based in Hyderabad, India, with more than 17 years of professional experience. I spent 12 years as a senior front-end developer, designing and building websites, and I have worked as a full-stack developer since 2022. I created oks-ui, a React component library with no runtime dependencies, and I write and maintain this site.

More about Omkar Sahu · All posts

Was this post helpful?

ShareXLinkedIn