Skip to main content
On this pageOverview

Drag and Drop

Overview

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.

Examples

Demo

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.

Backlog
Design API
Write tests
Build docs
Done
Set up repo
Add CI
// 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])],
    ),
  ),
)

Styling

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).

AttributeCondition
data-draggable-idSet on draggable items with the item ID.
data-sortable-idSet on sortable items with the item ID.
data-droppable-idSet on drop containers with the container ID.

Keyboard Interaction

DragAndDrop supports full keyboard navigation. Space/Enter activates drag mode, arrow keys move the item, Tab/Shift+Tab moves between containers, and Escape cancels.

KeyDescription
Space / EnterActivates keyboard drag mode on a focused item.
Arrow Up / DownMoves the item within its container (vertical orientation).
Arrow Left / RightMoves the item within its container (horizontal orientation).
Tab / Shift+TabMoves the item to the next / previous container.
Space / EnterConfirms the drop at the current position.
EscapeCancels the drag and returns the item to its original position.

Accessibility

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.

API Reference

InitConfig

Configuration object passed to DragAndDrop.init().

NameTypeDefaultDescription
idstring-Unique ID for the drag-and-drop instance.
orientation'Vertical' | 'Horizontal''Vertical'Item flow direction. Controls arrow key mapping.
activationThresholdnumber5Minimum pointer movement in pixels before a drag activates. Prevents accidental drags from clicks.

View Helpers

Functions for attaching drag-and-drop behavior to your elements and reading drag state.

NameTypeDefaultDescription
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.

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson