Sortable List
The core use of @domphy/dnd is a list whose items can be dragged to a new position. This page covers vertical and horizontal layouts, grids, sort thresholds, server persistence, and pinned (non-draggable) items.
Minimal Vertical List
Apply dragDrop(state) via $ on the list container. Render children reactively from the same state and give every child a stable _key:
import { toState } from "@domphy/core"
import { dragDrop } from "@domphy/dnd"
import { themeColor, themeSpacing } from "@domphy/theme"
const tasks = toState([
{ id: 1, title: "Design system" },
{ id: 2, title: "Write tests" },
{ id: 3, title: "Deploy staging" },
])
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)],
style: { listStyle: "none", padding: "0" },
}When a user drops an item in a new position, FormKit calls tasks.set(newOrder). Domphy's keyed diff re-renders only the moved items.
`_key` is required
Without a stable key Domphy cannot match DOM nodes to their positions after a reorder. Use the item's unique ID — never the array index.
Reading the Current Order
Because tasks is a reactive state, read it anywhere in the same tree:
const App = {
div: [
TaskList,
{
p: (l) =>
`Order: ${tasks.get(l).map((t) => t.id).join(" → ")}`,
style: { marginTop: themeSpacing(3) },
},
],
}The paragraph re-renders automatically after each drop.
Grid Layout
A grid is just a list with display: grid. dragDrop treats it as a flat array — dragging reorders the state, and the grid appearance is purely CSS:
const cards = toState([
{ id: 1, title: "Card A" },
{ id: 2, title: "Card B" },
{ id: 3, title: "Card C" },
{ id: 4, title: "Card D" },
{ id: 5, title: "Card E" },
{ id: 6, title: "Card F" },
])
const CardGrid = {
ul: (l) =>
cards.get(l).map((card) => ({
li: card.title,
_key: card.id,
style: {
padding: themeSpacing(4),
backgroundColor: (cl) => themeColor(cl, "shift-2"),
borderRadius: themeSpacing(2),
textAlign: "center",
cursor: "grab",
userSelect: "none",
},
})),
$: [dragDrop(cards)],
style: {
listStyle: "none",
padding: "0",
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: themeSpacing(3),
},
}No extra config is needed. The threshold option controls how far into a cell the cursor must travel before the swap fires.
Adjusting the Sort Threshold
By default a sort triggers when the cursor crosses 50 % of a target item. Tighten this for compact items or grids:
dragDrop(cards, {
threshold: { horizontal: 0.3, vertical: 0.3 },
})Values are fractions of the target's width/height (0 to 1).
Disabling Sort Within a List
Set sortable: false to prevent reordering inside the list while still allowing items to arrive from other lists in the same group:
{
ul: (l) => items.get(l).map(...),
$: [dragDrop(items, { sortable: false, group: "board" })],
}Persisting Order to a Server
Wire onSort to flush each reorder:
dragDrop(tasks, {
onSort: async ({ values }) => {
await fetch("/api/tasks/order", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: values.map((t) => t.id) }),
})
},
})values is the new ordered array after the sort. To roll back on failure, capture a snapshot in onDragstart:
type Task = { id: number; title: string }
let snapshot: Task[] = []
dragDrop(tasks, {
onDragstart: ({ values }) => {
snapshot = [...values] as Task[]
},
onSort: async ({ values }) => {
try {
await fetch("/api/tasks/order", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: (values as Task[]).map((t) => t.id) }),
})
} catch {
tasks.set(snapshot)
}
},
})Pinning Specific Items
The draggable callback receives each child's DOM element; return false to prevent that item from being dragged.
First, stamp a data-id attribute onto each item:
tasks.get(l).map((task) => ({
li: task.title,
_key: task.id,
dataId: String(task.id),
style: {
padding: themeSpacing(3),
marginBottom: themeSpacing(2),
backgroundColor: (cl) => themeColor(cl, "shift-2"),
borderRadius: themeSpacing(2),
cursor: task.pinned ? "default" : "grab",
opacity: task.pinned ? "0.5" : "1",
userSelect: "none",
},
}))Then filter in the config:
const pinned = new Set([1, 2]) // IDs that should not move
dragDrop(tasks, {
draggable: (el) => !pinned.has(Number(el.dataset.id)),
})Non-draggable items remain in position; other items sort around them.
If your decision is purely data-based you can use draggableValue instead, which receives the item value directly without needing a DOM attribute:
dragDrop(tasks, {
draggableValue: (task) => !(task as { pinned?: boolean }).pinned,
})