Accessibility
@domphy/dnd wraps @formkit/drag-and-drop, whose engine is pointer-based. Keyboard drag-and-drop is not implemented upstream (handleNodeKeydown is an empty stub; the only built-in key handling is Escape clearing a multi-drag selection), and FormKit does not manage tabindex, aria-grabbed, or aria-dropeffect for you. This page shows how to build the keyboard alternative yourself.
Keyboard Alternative: Reorder the State
The supported pattern is to make items focusable and reorder the bound state from a key handler. Because Domphy re-renders the keyed children from the same state the drag engine writes to, a keyboard reorder and a pointer drag stay in sync automatically:
import { toState } from "@domphy/core"
import { dragDrop } from "@domphy/dnd"
type Task = { id: number; title: string }
const tasks = toState<Task[]>([
{ id: 1, title: "Write specs" },
{ id: 2, title: "Build feature" },
{ id: 3, title: "Review PR" },
])
const announcement = toState("")
// Move the item at `index` by `delta` (-1 up, +1 down) and announce it.
function move(index: number, delta: number) {
const list = tasks.get()
const target = index + delta
if (target < 0 || target >= list.length) return
const next = list.slice()
const [item] = next.splice(index, 1)
next.splice(target, 0, item)
tasks.set(next)
announcement.set(
`"${item.title}" moved to position ${target + 1} of ${next.length}.`,
)
}
const App = {
div: [
{
ul: (l) =>
tasks.get(l).map((task, index) => ({
li: task.title,
_key: task.id,
tabindex: "0", // <li> is not natively focusable — add it yourself
onKeydown: (e: KeyboardEvent) => {
if (e.key === "ArrowUp") {
e.preventDefault()
move(index, -1)
} else if (e.key === "ArrowDown") {
e.preventDefault()
move(index, 1)
}
},
})),
$: [dragDrop(tasks)],
role: "list",
},
{
// Invisible ARIA live region — screen readers announce reorders politely.
div: (l) => announcement.get(l),
ariaLive: "polite",
ariaAtomic: "true",
},
],
}The same approach works between lists: move the item between the two state arrays. If you need the engine's own bookkeeping updated instead (e.g. mid-drag), the low-level performSort / performTransfer functions are re-exported from @domphy/dnd.
Add a focus-visible ring in a stylesheet so the focused item is visible:
const sheet = document.createElement("style")
sheet.textContent = `
li:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
`
document.head.appendChild(sheet)Announcing Reorders to Screen Readers
FormKit does not automatically announce sort results to screen readers. Add an ARIA live region and update it in onSort and onTransfer:
import { toState } from "@domphy/core"
import { dragDrop } from "@domphy/dnd"
import { themeColor, themeSpacing } from "@domphy/theme"
type Task = { id: number; title: string }
const tasks = toState<Task[]>([
{ id: 1, title: "Write specs" },
{ id: 2, title: "Build feature" },
{ id: 3, title: "Review PR" },
])
const announcement = toState("")
const TaskList = {
ul: (l) =>
tasks.get(l).map((task) => ({
li: task.title,
_key: task.id,
style: {
padding: themeSpacing(3),
marginBottom: themeSpacing(2),
backgroundColor: (cl) => themeColor(cl, "shift-2"),
borderRadius: themeSpacing(2),
cursor: "grab",
userSelect: "none",
},
})),
$: [
dragDrop(tasks, {
onSort: ({ values, previousPosition, position }) => {
const moved = values[position] as Task
announcement.set(
`"${moved.title}" moved from position ${previousPosition + 1} to ${position + 1} of ${values.length}.`,
)
},
onTransfer: ({ draggedNodes, targetParent }) => {
const names = draggedNodes.map((n) => (n.data.value as Task).title).join(", ")
announcement.set(`Transferred ${names} to ${targetParent.el.getAttribute("aria-label") ?? "another list"}.`)
},
}),
],
style: { listStyle: "none", padding: "0" },
}
// Invisible ARIA live region — screen readers announce changes politely.
const LiveRegion = {
div: (l) => announcement.get(l),
ariaLive: "polite",
ariaAtomic: "true",
style: {
position: "absolute",
width: "1px",
height: "1px",
padding: "0",
margin: "-1px",
overflow: "hidden",
clip: "rect(0,0,0,0)",
whiteSpace: "nowrap",
border: "0",
},
}
const App = {
div: [TaskList, LiveRegion],
}onSort receives previousPosition and position (both zero-based). Add 1 when building a human-readable message.
Touch and Long Press
On touch devices, drag starts immediately on pointerdown. For lists where items are also tappable, use longPress to require a sustained hold before the drag initiates:
dragDrop(tasks, {
longPress: true,
longPressDuration: 500,
longPressClass: "is-holding",
})const sheet = document.createElement("style")
sheet.textContent = `
.is-holding {
transform: scale(1.05);
transition: transform 0.2s;
box-shadow: 0 4px 16px rgba(0,0,0,.15);
}
`
document.head.appendChild(sheet)A normal tap (less than longPressDuration ms) fires click events as usual; a sustained press initiates the drag.
Reduced Motion
dragDrop() enables animations by default. Disable them for users who prefer reduced motion:
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches
dragDrop(tasks, {
animated: !reducedMotion,
})Column Labels for Screen Readers
In multi-container layouts, add aria-label to each list container. The transfer announcement above reads it via targetParent.el.getAttribute("aria-label"):
const TodoColumn = {
ul: (l) => todo.get(l).map((t) => ({ li: t.title, _key: t.id })),
$: [dragDrop(todo, { group: "kanban" })],
ariaLabel: "To Do column",
role: "list",
}
const DoneColumn = {
ul: (l) => done.get(l).map((t) => ({ li: t.title, _key: t.id })),
$: [dragDrop(done, { group: "kanban" })],
ariaLabel: "Done column",
role: "list",
}