Multi-Container
Assign the same group string to two or more lists and items can be dragged between them. This is the building block for Kanban boards, inbox/done pairs, and tag pickers.
Basic Two-Column Transfer
import { toState } from "@domphy/core"
import { dragDrop } from "@domphy/dnd"
import { themeColor, themeSpacing } from "@domphy/theme"
type Task = { id: number; title: string }
const todo = toState<Task[]>([
{ id: 1, title: "Write specs" },
{ id: 2, title: "Set up CI" },
])
const done = toState<Task[]>([
{ id: 3, title: "Design mockups" },
])
const GROUP = "kanban"
function listElement(label: string, state: ReturnType<typeof toState<Task[]>>) {
return {
div: [
{ h3: label },
{
ul: (l) =>
state.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(state, { group: GROUP })],
style: {
listStyle: "none",
padding: "0",
minHeight: themeSpacing(20),
},
},
],
style: {
flex: "1",
padding: themeSpacing(4),
backgroundColor: (l) => themeColor(l, "shift-1"),
borderRadius: themeSpacing(3),
},
}
}
const App = {
div: [
listElement("To Do", todo),
listElement("Done", done),
],
style: {
display: "flex",
gap: themeSpacing(4),
},
}Each toState tracks its own column. When a task is dragged from one column to another, FormKit calls setValues on both the source and the target — the reactive states update and Domphy re-renders both columns.
Empty columns
Set a minHeight on the <ul> so an empty column still has a drop area. Without height there is no DOM surface to drop onto.
Three-Column Kanban
Add as many lists as needed — all with the same group:
const todo = toState<Task[]>([...])
const inProgress = toState<Task[]>([...])
const done = toState<Task[]>([...])
const App = {
div: [
listElement("To Do", todo),
listElement("In Progress", inProgress),
listElement("Done", done),
],
style: { display: "flex", gap: themeSpacing(4) },
}Listening for Transfers
onTransfer fires on both the source and target lists when an item crosses a column boundary:
type TransferData<T> = {
sourceParent: { el: HTMLElement; data: import("@domphy/dnd").ParentData<T> }
targetParent: { el: HTMLElement; data: import("@domphy/dnd").ParentData<T> }
draggedNodes: Array<{ el: Node; data: import("@domphy/dnd").NodeData<T> }>
}
dragDrop(done, {
group: GROUP,
onTransfer: ({ draggedNodes, sourceParent, targetParent }) => {
console.log(
"Transferred",
draggedNodes.map((n) => (n.data.value as Task).title),
"from",
sourceParent.el.dataset.column,
"to",
targetParent.el.dataset.column,
)
},
})Restricting What Can Transfer
The accepts callback runs on the target list before a drop is committed. Return false to reject the incoming item:
// Only let tasks from the "todo" column enter the "done" column.
// accepts(targetParent, initialParent, currentParent, state)
dragDrop(done, {
group: GROUP,
accepts: (_target, initialParent) => {
return initialParent.el.id === "col-todo"
},
})initialParent is the list where the drag started. targetParent is the list being dragged over. Both are ParentRecord<T> — access the DOM element via .el and the config/values via .data.
A more data-driven check using the values currently in the source list:
dragDrop(done, {
group: GROUP,
accepts: (_target, initialParent) => {
const sourceTasks = initialParent.data.getValues(initialParent.el) as Task[]
return sourceTasks.every((t) => t.status === "todo")
},
})Preventing Items from Leaving a List
Set sortable: false on the target so received items cannot be reordered there, and add accepts on the source to block items from coming back:
const archive = toState<Task[]>([])
let archiveEl: HTMLElement | null = null
// Source: items can leave but cannot accept transfers
dragDrop(todo, {
group: "archive",
accepts: (_target, _initial, _current, state) => {
// reject drops that originate from archive
return state.initialParent.el !== archiveEl
},
})
// Sink: receives items, keeps insertion order, never gives them back
const ArchiveList = {
ul: (l) => archive.get(l).map((t) => ({ li: t.title, _key: t.id })),
$: [dragDrop(archive, { group: "archive", sortable: false })],
_onMount: (node) => {
archiveEl = node.domElement as HTMLElement
},
}Drop Zone Visual Feedback
Apply a class to the list container while it is being dragged over:
dragDrop(done, {
group: GROUP,
dropZoneParentClass: "list-active",
})Because FormKit adds and removes this class directly on the DOM element (outside Domphy's render cycle), style it in a global stylesheet:
const sheet = document.createElement("style")
sheet.textContent = `.list-active { outline: 2px dashed currentColor; }`
document.head.appendChild(sheet)dropZoneClass does the same but on the individual item being hovered over rather than the parent list.
Disabling Sorting Within Columns
Set sortable: false per column to turn columns into pure drop zones where the only way items move is by dragging from another column:
function dropOnlyColumn(label: string, state: ReturnType<typeof toState<Task[]>>) {
return listElement(label, state)
// override $:
// $: [dragDrop(state, { group: GROUP, sortable: false })]
}