On this pageOverview
Drag and Drop
Sortable lists and cross-container movement with pointer tracking, keyboard navigation, collision detection, auto-scrolling, and screen reader announcements.
DragAndDrop is different from other Foldkit UI components in two ways. First, it doesn’t have a view() function. Instead, you spread draggable() and droppable() attributes onto your own elements. Second, its update function returns a three-tuple: [model, commands, maybeOutMessage]. You handle Reordered and Cancelled OutMessages to decide how to reorder your data.
Integration requires four pieces: a DragAndDrop.Model field in your Model, update delegation with OutMessage handling, DragAndDrop.subscriptions for document-level pointer and keyboard listeners, and draggable() / droppable() attributes in your view.
See it in an app
Check out how DragAndDrop is wired up in the kanban example or the UI showcase.
The snippet below shows a minimal sortable list with all four integration pieces. For a full example with persistence, cross-container moves, and add-card forms, see the Kanban example.
// Pseudocode walkthrough of the Foldkit integration points. Each labeled
// block below is an excerpt — fit them into your own Model, init, Message,
// update, subscriptions, and view definitions.
import { Effect, Match as M, Option, Stream } from 'effect'
import { Command, Subscription, Ui } from 'foldkit'
import { html } from 'foldkit/html'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'
const h = html<Message>()
// Add a field to your Model for the DragAndDrop Submodel plus the items being sorted:
const Model = S.Struct({
items: S.Array(S.Struct({ id: S.String, label: S.String })),
dragAndDrop: Ui.DragAndDrop.Model,
// ...your other fields
})
// In your init function, initialize the DragAndDrop Submodel with a unique id:
const init = () => [
{
items: [
{ id: '1', label: 'First' },
{ id: '2', label: 'Second' },
{ id: '3', label: 'Third' },
],
dragAndDrop: Ui.DragAndDrop.init({ id: 'sortable-list' }),
// ...your other fields
},
[],
]
// Embed the DragAndDrop Message in your parent Message:
const GotDragAndDropMessage = m('GotDragAndDropMessage', {
message: Ui.DragAndDrop.Message,
})
// Inside your update function's M.tagsExhaustive({...}), DragAndDrop.update
// returns a three-tuple: [model, commands, maybeOutMessage]. Handle the
// Reordered OutMessage to apply the move to your own list:
GotDragAndDropMessage: ({ message: dragMessage }) => {
const [nextDragAndDrop, dragCommands, maybeOutMessage] =
Ui.DragAndDrop.update(model.dragAndDrop, dragMessage)
const mappedCommands = dragCommands.map(
Command.mapEffect(
Effect.map(message => GotDragAndDropMessage({ message })),
),
)
return Option.match(maybeOutMessage, {
onNone: () => [
// Merge the next state into your Model:
evo(model, { dragAndDrop: () => nextDragAndDrop }),
// Forward the Submodel's Commands through your parent Message:
mappedCommands,
],
onSome: outMessage =>
M.value(outMessage).pipe(
M.tagsExhaustive({
Reordered: ({ itemId, fromIndex, toIndex }) => [
// Merge the next state into your Model:
evo(model, {
// reorder is your own function that moves the item
items: () => reorder(model.items, itemId, fromIndex, toIndex),
dragAndDrop: () => nextDragAndDrop,
}),
// Forward the Submodel's Commands through your parent Message:
mappedCommands,
],
Cancelled: () => [
// Merge the next state into your Model:
evo(model, { dragAndDrop: () => nextDragAndDrop }),
// Forward the Submodel's Commands through your parent Message:
mappedCommands,
],
}),
),
})
}
// In your subscriptions, forward all four document-level listeners:
const dragAndDropSubs = Ui.DragAndDrop.subscriptions
const mapDragStream = stream =>
stream.pipe(Stream.map(message => GotDragAndDropMessage({ message })))
const dragFields = Ui.DragAndDrop.SubscriptionDeps.fields
const SubscriptionDeps = S.Struct({
dragPointer: dragFields['documentPointer'],
dragEscape: dragFields['documentEscape'],
dragKeyboard: dragFields['documentKeyboard'],
autoScroll: dragFields['autoScroll'],
})
const subscriptions = Subscription.makeSubscriptions(SubscriptionDeps)({
dragPointer: {
modelToDependencies: model =>
dragAndDropSubs.documentPointer.modelToDependencies(model.dragAndDrop),
dependenciesToStream: (deps, readDeps) =>
mapDragStream(
dragAndDropSubs.documentPointer.dependenciesToStream(deps, readDeps),
),
},
dragEscape: {
modelToDependencies: model =>
dragAndDropSubs.documentEscape.modelToDependencies(model.dragAndDrop),
dependenciesToStream: (deps, readDeps) =>
mapDragStream(
dragAndDropSubs.documentEscape.dependenciesToStream(deps, readDeps),
),
},
dragKeyboard: {
modelToDependencies: model =>
dragAndDropSubs.documentKeyboard.modelToDependencies(model.dragAndDrop),
dependenciesToStream: (deps, readDeps) =>
mapDragStream(
dragAndDropSubs.documentKeyboard.dependenciesToStream(deps, readDeps),
),
},
autoScroll: {
modelToDependencies: model =>
dragAndDropSubs.autoScroll.modelToDependencies(model.dragAndDrop),
dependenciesToStream: (deps, readDeps) =>
mapDragStream(
dragAndDropSubs.autoScroll.dependenciesToStream(deps, readDeps),
),
},
})
// Inside your view function, spread draggable() onto items and droppable()
// onto containers:
h.ul(
[
...Ui.DragAndDrop.droppable('list', 'Sortable items'),
h.Class('flex flex-col gap-2'),
],
model.items.map((item, index) =>
h.li(
[
...Ui.DragAndDrop.draggable({
model: model.dragAndDrop,
toParentMessage: message => GotDragAndDropMessage({ message }),
itemId: item.id,
containerId: 'list',
index,
}),
h.Class('p-3 rounded-lg border cursor-grab'),
],
[h.span([], [item.label])],
),
),
)DragAndDrop is fully headless. You render all items, containers, and ghost elements. Use isDragging() and maybeDraggedItemId() to conditionally style items during drag (e.g. reduced opacity on the source, a drop placeholder at the target).
| Attribute | Condition |
|---|---|
data-draggable-id | Set on draggable items with the item ID. |
data-sortable-id | Set on sortable items with the item ID. |
data-droppable-id | Set on drop containers with the container ID. |
DragAndDrop supports full keyboard navigation. Space/Enter activates drag mode, arrow keys move the item, Tab/Shift+Tab moves between containers, and Escape cancels.
| Key | Description |
|---|---|
| Space / Enter | Activates keyboard drag mode on a focused item. |
| Arrow Up / Down | Moves the item within its container (vertical orientation). |
| Arrow Left / Right | Moves the item within its container (horizontal orientation). |
| Tab / Shift+Tab | Moves the item to the next / previous container. |
| Space / Enter | Confirms the drop at the current position. |
| Escape | Cancels the drag and returns the item to its original position. |
Draggable items receive role="option" with aria-roledescription="draggable". Drop containers receive role="listbox". Screen reader announcements are emitted for drag start, movement, and drop via a live region.
Configuration object passed to DragAndDrop.init().
| Name | Type | Default | Description |
|---|---|---|---|
id | string | - | Unique ID for the drag-and-drop instance. |
orientation | 'Vertical' | 'Horizontal' | 'Vertical' | Item flow direction. Controls arrow key mapping. |
activationThreshold | number | 5 | Minimum pointer movement in pixels before a drag activates. Prevents accidental drags from clicks. |
Functions for attaching drag-and-drop behavior to your elements and reading drag state.
| Name | Type | Default | Description |
|---|---|---|---|
draggable(config) | ReadonlyArray<Attribute> | - | Spread onto draggable items. Attaches pointer-down, keyboard activation, and ARIA attributes. Config requires model, toParentMessage, itemId, containerId, and index. |
droppable(containerId, label?) | ReadonlyArray<Attribute> | - | Spread onto drop containers. Attaches the container ID for collision detection and optional ARIA label. |
sortable(itemId) | ReadonlyArray<Attribute> | - | Spread onto items that are both draggable and sortable targets. |
ghostStyle(model) | Option<CSSProperties> | - | Returns positioning styles for a ghost element that follows the pointer during drag. Use with Option.match to conditionally render. |
isDragging(model) | boolean | - | Whether a drag is currently in progress. |
maybeDraggedItemId(model) | Option<string> | - | The ID of the item being dragged, if any. |
maybeDropTarget(model) | Option<DropTarget> | - | The current drop target (containerId + index), if any. |