Drag and Drop
Declarative drag-and-drop in Hypen — sortable lists, drop zones, pinboards, the .bind write, event payloads, keyboard operation, and per-platform support
Drag and Drop
Hypen does drag-and-drop declaratively: four role applicators say what a node is in the gesture, .bind() says which state the drop writes, and six event applicators say which action to dispatch. The renderer runs the drag preview locally — ghost, sibling shifts, zone highlight. It dispatches the final write and events at drop; opted-in start and hover events can also call your module during the gesture. No per-frame drag position enters state, and a renderer that doesn't implement dragging shows the same list, static and correct.
| Construct | Declares |
|---|---|
.draggable() | A node the user can pick up |
.dropZone() | A target a compatible drag can be dropped into |
.sortable() | A container whose ForEach children reorder by dragging |
.pinboard() | A Stack whose ForEach children can be dropped anywhere and stay there |
.bind(@state.path) | The state the drop mutates — applied for you, before any event |
.onDragStart .onDragOver .onDrop .onSort .onPin .onDragEnd | Actions to dispatch |
.states { onState(lifted) … onState(over) … } | Looks driven by the drag itself |
Setup and Host Support
Use an engine, host SDK, and renderer built with DnD support. Standard DOM, Canvas, SwiftUI, Compose, and desktop renderers attach their coordinator automatically; there is no plugin registration or feature flag. Put a stable key: on the ForEach, .draggable() on the lift surface, and .sortable().bind(@state.tasks) on the container. The TypeScript, Go, Kotlin, Swift, and Rust hosts register the outcome handlers automatically. Add .onSort only when you need application work such as saving to a server; define that action in the module as usual.
Ownership and versions: Use matching engine, host, and renderer builds. Renderers send the originating node with each UI action; the engine resolves its live module and invokes that module's handler. Bind, reorder, pin, and DnD callbacks can safely reuse names and paths across modules. Stale or detached nodes and ambiguous legacy dispatches are rejected. Upgrade all three layers together: older engines do not recognize the new internal dispatch envelope.
Automatic list transfers require both lists to belong to the same module. For a cross-module transfer, handle the destination's .onSort and mutate both modules through the application context. Its payload includes fromScope and toScope (lowercase module names; empty string for the primary slot). The automatic write is rejected and the drag preview expires without changing either list.
All five hosts — TypeScript, Go, Kotlin, Swift, and Rust — register automatic reorder/pin handlers. Fractional pins and custom coordinate field names work in reserved and bound modes. No extra module action registration is needed.
Quick Start
// Reorder a list: two applicators, no handler
Column {
ForEach(items: @state.tasks, key: "id") {
TaskRow("@{item.title}").draggable()
}
}
.sortable(axis: y)
.bind(@state.tasks) // the engine reorders state.tasks for you
// Kanban: sort within a column AND move between columns, then tell the server
Column { ForEach(items: @state.todo, key: "id") { Card("@{item.title}").draggable() } }
.sortable(group: "board").bind(@state.todo).onSort(@actions.persistBoard)
Column { ForEach(items: @state.doing, key: "id") { Card("@{item.title}").draggable() } }
.sortable(group: "board").bind(@state.doing).onSort(@actions.persistBoard)
// Drop onto a target — your module owns the mutation
Row { Text("Trash") }
.dropZone(group: "board", id: "trash")
.onDrop(@actions.deleteCard)
.states { onState(over).backgroundColor("#fee2e2") }
// Pinboard: drop anywhere, stay there
Stack {
ForEach(items: @state.notes, key: "id") {
StickyNote("@{item.text}").draggable()
}
}
.size(1200, 800)
.pinboard(group: "board", grid: 8)Identity is the ForEach key. .draggable() takes no id: the engine knows each row's key (key: field, else the item's id, else <itemName>-<index> — so a primitive-array row is "item-0"), and that key is the item in every event payload and the key the pinboard stores positions under.
Lift Surfaces — .draggable()
.draggable() // inherits group and axis from the enclosing .sortable / .pinboard
.draggable(group: "cards", payload: @item) // group restricts where it can land; payload rides in every event
.draggable(handle: true) // informational — a .draggable always lifts from its own subtree; the row moves
.draggable(activation: press) // auto | slop | press | immediate
.draggable(enabled: @state.canEdit) // bindable- Arguments are named-only (a positional
.draggable("cards")warns and is ignored).group:andactivation:are static;payload:andenabled:may bind. group:defaults to the enclosing sortable's or pinboard's group. An ungrouped source lands only on ungrouped zones and its own container.- The grip pattern is the "just make it unambiguous" answer on touch: put
.draggable()on the grip node and only the grip lifts, so buttons in the row stay buttons and scrolling stays scrolling. The lift surface of any.draggableis its own subtree on every renderer;handle: truedocuments the intent but no renderer reads it in v1 — it never restricts or extends the lift surface. - A disabled source never claims the pointer and is skipped by keyboard lift.
Platform Support: Web, Canvas, Desktop, iOS, Android
Drop Targets — .dropZone()
.dropZone(group: "fs", id: "trash") // id is what from.zone / to.zone carry
.dropZone(group: "fs", id: "@{item.id}", enabled: @item.isFolder)
.dropZone(band: 0.3) // narrower "into" band on a sortable itemid:defaults to the node'sidprop, else the node id; templates like"zone-@{item.id}"re-resolve.enabled:(bindable, defaulttrue) — a disabled zone is transparent to drags.band:(0..1, default0.5) — the middle fraction of a sortable item that means "into"; see the band rule below.- The innermost enabled, group-compatible zone under the pointer wins. A source is never a zone for itself.
Platform Support: Web, Canvas, Desktop, iOS, Android
Reorderable Lists — .sortable()
Column { ForEach(items: @state.tasks, key: "id") { TaskRow("@{item.title}").draggable() } }
.sortable() // group from the node's static id (else self-only); axis y
.sortable(group: "board", axis: x) // accepts drops from sibling lists of the same group
.bind(@state.tasks) // the write target — omit it to own the mutationgroup:defaults to the node's staticid(theidargument or.id("…")), elsenull— a self-only list. Lists sharing a group transfer items between each other.axis:(x|y, defaulty) is the sort axis and drives the touch activation rule.- Siblings shift to open the gap while the drag is live; on drop the local preview is held until the engine's
Movepatches land, so nothing flashes. - The element that moves is the sortable's direct child containing the source —
ForEach { Row { Icon().draggable(handle: true) Text() } }moves the whole row.
Platform Support: Web, Canvas, Desktop, iOS, Android
Free Placement — .pinboard()
// Reserved-state mode: positions live in module state under __dnd.<group>.<key>
Stack { ForEach(items: @state.notes, key: "id") { StickyNote("@{item.text}").draggable() } }
.size(1200, 800)
.pinboard(group: "board", grid: 8)
// User-field mode: positions ARE your data
Stack { ForEach(items: @state.seats, key: "id") {
Seat("@{item.label}").translateX(@item.x).translateY(@item.y).draggable()
} }
.pinboard(x: "x", y: "y")
.bind(@state.seats)
.onPin(@actions.seatMoved)group:(defaults to the staticid) — required in reserved-state mode; without it the board warns once and lowers nothing.x:/y:name the fields (default"x"/"y");grid:snaps;bounds:isclamp(default) orfree;units:ispx(default) orfraction(normalized coordinates automatically projected against the current content-box size).- Mode is decided by
.bind. Without it, position is presentation state: the engine injectstranslateX/translateYbindings to the reserved__dnd.<group>.<key>subtree of the module's state, your item type grows no coordinates, and an unpinned item sits at the origin. With.bind(@state.list)andunits: px, author.translateX(@item.x).translateY(@item.y)and the drop writes your fields. Withunits: fraction, omit these translates: the engine supplies normalized positions from your item fields. An explicit translate on an axis overrides the automatic position on that axis. - Coordinates are the item's top-left relative to the board's content box (inside padding), after grid snap, clamp, and the units conversion.
Platform Support: Web, Canvas, Desktop, iOS, Android
Fractional coordinates and custom fields
Stack { ForEach(items: @state.notes, key: "id") {
StickyNote("@{item.text}").draggable()
} }
.pinboard(group: "board", x: "left", y: "top", units: fraction)This stores state.__dnd.board.<key>.left and .top. A value of 0.5 means half the board's content width/height, including after resize. Add .bind(@state.notes) to store those fields on each note instead. In fraction mode, omit authored pixel translates unless you intend to override an axis. Coordinate fields must be nonempty leaf names (no dots); __proto__, prototype, and constructor are not allowed.
The Write — .bind()
.bind(@state.path) on a sortable or pinboard names the state the drop mutates, and the SDK applies that mutation before any event fires — through the module's tracked state, so it invalidates dependents, persists with .persist(...), and streams under Remote UI like any mutation of your own.
| Role | What the drop does |
|---|---|
.sortable().bind(@state.tasks) | path_move: the item is spliced out and re-inserted at its final index. Same-list reorders and transfers between two bound lists of one group both work. |
.pinboard(...) | Two field sets (x, y), batched into one flush — your fields in user-field mode, __dnd.<group>.<key> in reserved mode. |
- An unbound sortable does not write state. Handle
.onSortto own the mutation. An unbound pinboard is different: it automatically writes reserved__dndpositions. - A drop back on the origin slot writes nothing and fires only
.onDragEnd { dropped: true }. - Drops into a
.dropZonenever write — a zone is an event target; your.onDrophandler moves the data.
The reserved __dnd subtree is ordinary module state: it persists, restores before onCreated, and can be seeded in defineState for initial positions. On typed hosts (Kotlin, Swift, Rust) top-level __ keys are preserved across the typed round-trip, so the reserved form is safe with no schema obligation; for the .bind form the fields named by x:/y: must exist on the item type (the SDK warns once per dropped path). Sort is immune — it adds no keys.
Events
.onDragStart(@actions.dragBegan) // on the draggable, or on the container to cover all children
.onDragOver(@actions.openFolder, dwell: 600) // on a zone: fires once per entry after dwell ms (default 500)
.onDrop(@actions.moveInto) // on a zone: the drop resolved INTO it
.onSort(@actions.persistBoard) // on a sortable: fires on the DESTINATION list, after the write
.onPin(@actions.seatMoved) // on a pinboard: after the write
.onDragEnd(@actions.dragEnded) // drop AND cancel: payload.dropped is true | falseEvery event receives the same payload, so one handler can serve many zones:
{
item: string, // the ForEach key of the dragged node
payload?: unknown, // .draggable(payload:)
from: { zone: string, index: number | null },
to: { zone: string, index: number | null }, // index null = "into", not "at"
x?: number, y?: number, // .onPin only — board content-box units
dropped?: boolean, // .onDragEnd only
}zone is a sortable's/pinboard's group (else its id, else the node id) or a .dropZone's id:. Order on drop: the write, then .onSort / .onPin / .onDrop, then .onDragEnd { dropped: true }. A cancel fires only .onDragEnd { dropped: false }; a node removed mid-drag fires nothing.
import { app, type DndEventPayload } from "@hypen-space/core";
type Card = { id: string; title: string };
export default app
.defineState<{ todo: Card[]; doing: Card[] }>({ todo: [], doing: [] })
.onAction<DndEventPayload>("deleteCard", ({ action, state }) => {
if (!action.payload) return;
// Both lists share group "board", so from.zone cannot identify the list.
// Stable item keys identify the card across the two lists.
const id = action.payload.item;
state.todo = state.todo.filter(card => card.id !== id);
state.doing = state.doing.filter(card => card.id !== id);
})
.onAction("persistBoard", async ({ state }) => {
// .bind already applied the move. Replace with your persistence call.
console.log({ todo: state.todo.map(card => card.id), doing: state.doing.map(card => card.id) });
})
.build();Drag-Driven Looks — .states { } without a state path
A header-less .states block on a node with a DnD role is driven by the drag itself, not by state: lifted applies to the source while it is in the air, over to a zone while a compatible drag hovers it. They are ordinary .states poses — cross-renderer looks, not CSS — with the usual pose rules (static values only).
Card("@{item.title}")
.draggable()
.states { onState(lifted).opacity(0.6).scale(1.04).tw("shadow-xl") }
Column { ... }
.dropZone(group: "cards", id: "doing")
.states(transition: easeOut, duration: 120) { onState(over).backgroundColor("#eef2ff") }The synthesized transition glides the switch on the web and iOS; desktop and Android snap it. A .states(@state.x) { … } with a header on the same node behaves exactly as before.
Platform Support: Web, Canvas, Desktop, iOS, Android
Activation and Handles
A drag that starts on pointer-down steals scrolling, so the default auto picks the rule per input:
| Input | auto means |
|---|---|
| Mouse / pen | 6px of travel along any axis — a tap is a total no-op and child clicks pass through |
Touch inside an axis-constrained .sortable | slop on the cross axis: sideways lifts, along-the-list scrolls |
| Touch elsewhere (pinboards, loose draggables) | a ~300ms long-press; moving first scrolls |
.draggable() on a grip node | the same rules, on the grip's subtree only — the enclosing row moves |
Override per source with activation: slop | press | immediate. Prefer a grip on mobile — it removes the ambiguity entirely. The grip works because the lift surface is always the .draggable node's own subtree; handle: true changes nothing by itself.
The Band Rule
An item that is both a sortable row and a .dropZone (a folder in a file list) is split along the sort axis: the middle band fraction (default 50%) means into (.onDrop), the outer parts mean insert before/after (a reorder). A zone with enabled: false has no inner band and is an ordinary row.
Column {
ForEach(items: @state.entries, key: "id") {
Row { Icon("@{item.kind}") Text("@{item.name}") }
.draggable(group: "fs")
.dropZone(group: "fs", id: "@{item.id}", enabled: @item.isFolder)
.onDrop(@actions.moveInto)
.onDragOver(@actions.openFolder, dwell: 600) // spring-loaded folders
}
}
.sortable(group: "fs").bind(@state.entries)Rows inserted mid-drag (the folder opening) become live targets immediately. The renderer never rejects a semantically invalid drop (a folder into its own descendant) — check that in your handler.
Keyboard
DOM and Canvas sortable drags are keyboard-operable with no extra markup, and it emits the identical events: Tab to a draggable, Space to lift, arrow keys to move within the list, Tab/Shift+Tab to move between zones, Space to drop, Esc to cancel. The web renderer announces each step through a polite live region — the sentence names the item by its ForEach key, not a title ("t3, position 2 of 5"), so choose readable keys or supply an accessible name yourself; Canvas keyboard drag sets aria-grabbed on the accessibility mirror but has no announcements in v1 (its describeKeyboard() names foreign zones by node id); Android exposes "Move up/down" TalkBack actions instead. iOS and desktop have no keyboard drag path; provide ordinary move controls for users who cannot drag. Pinboard items cannot be lifted from the keyboard in v1, the machine's zone counts are snapshotted at lift (a keyboard drop after a mid-drag row insert may carry an index the host clamps), and tabbing to another list opens its gap without previewing the item inside it.
Degradation
Two mid-drag edge rules to know: a new press during the post-drop hold window (until the engine's re-render lands, at most 500ms) is ignored — no events; and a draggable whose enabled: binding flips to false while it is in the air cancels silently, with no .onDragEnd, exactly like a mid-drag Remove.
Malformed input warns once and falls back — never an error: positional arguments are ignored, unknown tokens take their default, an out-of-range band: becomes 0.5, a reserved-mode pinboard with no group lowers nothing (its draggables still work as loose sources), and a renderer that doesn't implement the channel shows a static, correct list. Reduced motion never affects the drag itself (direct manipulation); only the sibling shift and pose transitions snap.
Platform Support Summary
| Channel | Web (DOM) | Canvas 2D | Desktop | iOS | Android |
|---|---|---|---|---|---|
.draggable activation | Mouse + touch rules | Mouse only | Mouse only | Touch rules (DragGesture / long press) | Mouse + touch rules |
.sortable preview | Yes (150ms shift) | Yes (shift snaps) | Yes (shift snaps) | Yes (150ms shift) | Yes (150ms shift) |
.dropZone + band | Yes | Yes | Yes | Yes | Yes |
.pinboard (both modes) | Yes | Yes | Yes | Yes | Yes |
lifted / over poses | Glide | Yes | Snap | Glide | Snap |
| Keyboard drag | Yes (live region) | Yes (aria-grabbed only, no announcements) | No | No | TalkBack custom actions |
.bind writes + events | Automatic host writes | Automatic host writes | Rust host | Swift host | Kotlin/Go/TS/Swift host |
There is no autoscroll in v1. handle: true is informational; put .draggable() on the grip itself. Swift renderer and server tests, an iOS Simulator build, and Android renderer unit tests were verified on a Mac. These checks do not replace device testing of gesture competition, VoiceOver/TalkBack, and scroll containers. The full matrices and wire contract live in the SDK reference, hypen-web/docs/dnd.md.