diff --git a/.changeset/drag-new-package.md b/.changeset/drag-new-package.md
new file mode 100644
index 000000000..8b7bf5043
--- /dev/null
+++ b/.changeset/drag-new-package.md
@@ -0,0 +1,29 @@
+---
+"@solid-primitives/drag-drop": minor
+---
+
+New package: `@solid-primitives/drag-drop` — composable drag-and-drop primitives for Solid 2.0.
+
+### Exports
+
+**Pointer-event DnD** (UI element dragging):
+- `makeDraggable` — non-reactive base, no Solid owner required
+- `makeDroppable` — non-reactive drop target base
+- `createDraggable` — reactive draggable with `isDragging`, `transform`, auto style/class; keyboard-accessible (`Space`/`Enter` to pick up/drop, arrow keys to nudge, `Escape` to cancel)
+- `createDroppable` — reactive drop target with `isOver`, `active`, auto style/class
+- `createSortable` — combines draggable + droppable on the same element
+- `createDragContext` — coordinates a tree of draggables and droppables, with optional `autoScroll` near viewport edges
+- `arrayMove` — pure reorder helper for `onDragEnd` + `createSortable`
+
+**Native HTML5 DnD** (file drops, `draggable="true"` elements):
+- `makeNativeDroppable` — non-reactive base with depth-counter fix for child elements
+- `createNativeDroppable` — reactive native drop zone for OS file drops
+
+**Collision detection strategies** (pure functions, pass to `createDragContext`):
+- `closestCenter`, `closestCorners`, `rectIntersection`, `pointerWithin` (default)
+
+### Notes
+
+- The reported `transform` is corrected for page scroll during a drag, so the dragged element doesn't visually drift from the pointer if the page scrolls mid-drag (needed for `autoScroll` to look right, and for any scrollable page in general).
+- `createDroppable`/`createSortable` log a dev-mode warning when used without a `createDragContext` ancestor, since `isOver`/`active` silently stay `false`/`null` in that case.
+- `isDragging`/`isOver` are backed by `createProjection` instead of a per-instance `active()?.id === id` memo, so a drag start/end or hover change in a large list only notifies the specific items involved.
diff --git a/deno.lock b/deno.lock
index 52e0d6499..36a3baa9d 100644
--- a/deno.lock
+++ b/deno.lock
@@ -12,7 +12,7 @@
"npm:@graphql-typed-document-node/core@^3.2.0": "3.2.0_graphql@16.14.2",
"npm:@iconify-json/ph@^1.2.2": "1.2.2",
"npm:@iconify-json/ri@^1.2.10": "1.2.10",
- "npm:@kobalte/solidbase@~0.6.13": "0.6.13_@solidjs+start@2.0.0__@solidjs+router@0.15.4___solid-js@1.9.14__vite@8.1.3___@types+node@22.20.0___esbuild@0.28.1___yaml@2.9.0__@testing-library+jest-dom@6.9.1__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0_solid-js@2.0.0-beta.34_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0_@testing-library+jest-dom@6.9.1_@types+node@22.20.0_esbuild@0.28.1",
+ "npm:@kobalte/solidbase@~0.6.13": "0.6.13_@solidjs+start@2.0.0__@solidjs+router@0.15.4___solid-js@2.0.0-beta.34__vite@8.1.3___@types+node@22.20.0___esbuild@0.28.1___yaml@2.9.0_solid-js@2.0.0-beta.34_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0",
"npm:@nothing-but/node-resolve-ts@^1.0.1": "1.0.1",
"npm:@reduxjs/toolkit@^1.9.7": "1.9.7_react@19.2.7",
"npm:@solidjs/meta@~0.29.4": "0.29.4_solid-js@2.0.0-beta.34",
@@ -57,7 +57,7 @@
"npm:storybook@^10.4.6": "10.4.6_@types+react@19.2.17_prettier@3.9.4_@testing-library+dom@10.4.1_react@19.2.7",
"npm:tsdown@~0.22.3": "0.22.3_typescript@6.0.3",
"npm:typescript@^7.0.2": "7.0.2",
- "npm:unplugin-solid@1": "1.0.0_solid-js@2.0.0-beta.34_@types+node@22.20.0_esbuild@0.28.1_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0",
+ "npm:unplugin-solid@1": "1.0.0_solid-js@2.0.0-beta.34",
"npm:vite-plugin-solid@3.0.0-next.5": "3.0.0-next.5_@solidjs+web@2.0.0-beta.34__solid-js@2.0.0-beta.34_@testing-library+jest-dom@6.9.1_solid-js@2.0.0-beta.34_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0_@types+node@22.20.0_esbuild@0.28.1",
"npm:vite@8": "8.1.3_@types+node@22.20.0_esbuild@0.28.1_yaml@2.9.0",
"npm:vite@^6.4.3": "6.4.3_@types+node@22.20.0_sass@1.101.0",
@@ -1980,7 +1980,7 @@
"solid-prevent-scroll"
]
},
- "@kobalte/solidbase@0.6.13_@solidjs+start@2.0.0__@solidjs+router@0.15.4___solid-js@1.9.14__vite@8.1.3___@types+node@22.20.0___esbuild@0.28.1___yaml@2.9.0__@testing-library+jest-dom@6.9.1__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0_solid-js@2.0.0-beta.34_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0_@testing-library+jest-dom@6.9.1_@types+node@22.20.0_esbuild@0.28.1": {
+ "@kobalte/solidbase@0.6.13_@solidjs+start@2.0.0__@solidjs+router@0.15.4___solid-js@2.0.0-beta.34__vite@8.1.3___@types+node@22.20.0___esbuild@0.28.1___yaml@2.9.0_solid-js@2.0.0-beta.34_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0": {
"integrity": "sha512-rERXMPu+NiWqkB8EvHizUQareRqKVtUdCa0mjVIJtiJuUAmMR6M3VgHWH/C0llk/0ph/7VkYcwdbBXYLerDGtQ==",
"dependencies": [
"@alloc/quick-lru",
@@ -2006,7 +2006,7 @@
"@solid-primitives/storage",
"@solidjs/meta@0.29.4_solid-js@2.0.0-beta.34",
"@solidjs/router@0.15.4_solid-js@2.0.0-beta.34",
- "@solidjs/start@2.0.0_@solidjs+router@0.15.4__solid-js@1.9.14_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0_@testing-library+jest-dom@6.9.1_@types+node@22.20.0_esbuild@0.28.1_yaml@2.9.0",
+ "@solidjs/start@2.0.0_@solidjs+router@0.15.4__solid-js@2.0.0-beta.34_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0",
"cross-spawn",
"diff",
"esast-util-from-js",
@@ -2121,8 +2121,6 @@
"@napi-rs/wasm-runtime@1.1.6_@emnapi+core@1.11.1_@emnapi+runtime@1.11.1": {
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"dependencies": [
- "@emnapi/core@1.11.1",
- "@emnapi/runtime@1.11.1",
"@tybys/wasm-util"
]
},
@@ -2137,8 +2135,6 @@
"@napi-rs/wasm-runtime@1.1.6_@emnapi+core@1.9.2_@emnapi+runtime@1.9.2": {
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"dependencies": [
- "@emnapi/core@1.9.2",
- "@emnapi/runtime@1.9.2",
"@tybys/wasm-util"
]
},
@@ -3184,12 +3180,6 @@
"solid-js@2.0.0-beta.34"
]
},
- "@solidjs/router@0.15.4_solid-js@1.9.14": {
- "integrity": "sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ==",
- "dependencies": [
- "solid-js@1.9.14"
- ]
- },
"@solidjs/router@0.15.4_solid-js@2.0.0-beta.34": {
"integrity": "sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ==",
"dependencies": [
@@ -3211,14 +3201,14 @@
"@solidjs/signals@2.0.0-beta.34": {
"integrity": "sha512-i/mvOH8XHOPKOUvXTdj/BxJMkqi+x5/dFGTHyYnLe//7CTo23eYXbQI0AH1EPFNkBrPyy/Ly5Ur74vA+8tEnDg=="
},
- "@solidjs/start@2.0.0_@solidjs+router@0.15.4__solid-js@1.9.14_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0_@testing-library+jest-dom@6.9.1_@types+node@22.20.0_esbuild@0.28.1_yaml@2.9.0": {
+ "@solidjs/start@2.0.0_@solidjs+router@0.15.4__solid-js@2.0.0-beta.34_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0": {
"integrity": "sha512-OXO2BzEi5aqEgPqu9bbSXlKuAr9Pp4D33hJ0cAJh65fQFr6Zn+IxeMSpIyTeR8/Mzz2SAJwGAxwvMuvdZ1ylZQ==",
"dependencies": [
"@babel/core",
"@babel/traverse",
"@babel/types@7.29.7",
"@solidjs/meta@0.29.4_solid-js@1.9.14",
- "@solidjs/router@0.15.4_solid-js@1.9.14",
+ "@solidjs/router@0.15.4_solid-js@2.0.0-beta.34",
"@types/babel__traverse",
"@types/micromatch",
"cookie-es",
@@ -3243,7 +3233,7 @@
"vite-plugin-solid@2.11.14_@testing-library+jest-dom@6.9.1_solid-js@1.9.14_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0_@types+node@22.20.0_esbuild@0.28.1_yaml@2.9.0"
],
"optionalPeers": [
- "@solidjs/router@0.15.4_solid-js@1.9.14"
+ "@solidjs/router@0.15.4_solid-js@2.0.0-beta.34"
]
},
"@solidjs/start@2.0.0_@solidjs+router@1.0.0__solid-js@1.9.14_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0_@testing-library+jest-dom@6.9.1_@types+node@22.20.0_esbuild@0.28.1": {
@@ -4078,11 +4068,7 @@
"integrity": "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg==",
"dependencies": [
"@babel/core",
- "babel-plugin-jsx-dom-expressions",
- "solid-js@2.0.0-beta.34"
- ],
- "optionalPeers": [
- "solid-js@2.0.0-beta.34"
+ "babel-plugin-jsx-dom-expressions"
]
},
"babel-preset-solid@2.0.0-beta.17_@babel+core@7.29.7_solid-js@2.0.0-beta.34": {
@@ -4105,10 +4091,6 @@
"base64-js@1.5.1": {
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
},
- "baseline-browser-mapping@2.10.42": {
- "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==",
- "bin": true
- },
"baseline-browser-mapping@2.11.14": {
"integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==",
"bin": true
@@ -4154,7 +4136,7 @@
"browserslist@4.28.8": {
"integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
"dependencies": [
- "baseline-browser-mapping@2.11.14",
+ "baseline-browser-mapping",
"caniuse-lite",
"electron-to-chromium",
"node-releases",
@@ -6639,10 +6621,6 @@
"mute-stream@0.0.8": {
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="
},
- "nanoid@3.3.15": {
- "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
- "bin": true
- },
"nanoid@3.3.18": {
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"bin": true
@@ -6655,7 +6633,6 @@
"dependencies": [
"@next/env",
"@swc/helpers",
- "baseline-browser-mapping@2.10.42",
"postcss@8.4.31",
"react",
"react-dom",
@@ -7122,7 +7099,6 @@
"postcss@8.4.31": {
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"dependencies": [
- "nanoid@3.3.15",
"picocolors",
"source-map-js"
]
@@ -7130,7 +7106,7 @@
"postcss@8.5.26": {
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"dependencies": [
- "nanoid@3.3.18",
+ "nanoid",
"picocolors",
"source-map-js"
]
@@ -8362,7 +8338,7 @@
"unplugin"
]
},
- "unplugin-solid@1.0.0_solid-js@2.0.0-beta.34_@types+node@22.20.0_esbuild@0.28.1_vite@8.1.3__@types+node@22.20.0__esbuild@0.28.1__yaml@2.9.0": {
+ "unplugin-solid@1.0.0_solid-js@2.0.0-beta.34": {
"integrity": "sha512-pv1CS3XMtf3WwX8Dq9Bvo4qH6mfjN2xOgbaPcnqW1dLhyP/JQCvueGEsN0dYIZ4JvxaD/G/Ot1JnBzNQGHkfeA==",
"dependencies": [
"@babel/core",
@@ -8962,6 +8938,14 @@
]
}
},
+ "packages/drag-drop": {
+ "packageJson": {
+ "dependencies": [
+ "npm:@solidjs/web@2.0.0-beta.34",
+ "npm:solid-js@2.0.0-beta.34"
+ ]
+ }
+ },
"packages/event-bus": {
"packageJson": {
"dependencies": [
diff --git a/packages/drag-drop/LICENSE b/packages/drag-drop/LICENSE
new file mode 100644
index 000000000..ba8f69c3d
--- /dev/null
+++ b/packages/drag-drop/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Solid Primitives Working Group
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/drag-drop/README.md b/packages/drag-drop/README.md
new file mode 100644
index 000000000..51284c0e1
--- /dev/null
+++ b/packages/drag-drop/README.md
@@ -0,0 +1,288 @@
+
+
+
+
+# @solid-primitives/drag-drop
+
+[](https://bundlephobia.com/package/@solid-primitives/drag-drop)
+[](https://www.npmjs.com/package/@solid-primitives/drag-drop)
+[](https://github.com/solidjs-community/solid-primitives#contribution-process)
+
+Composable, tree-shakeable drag-and-drop primitives.
+
+`createDraggable` is keyboard-accessible out of the box — `Space`/`Enter` picks up the drag, arrow keys nudge it, `Space`/`Enter` drops it, and `Escape` cancels, mirroring the pointer sensor's lifecycle.
+
+Two separate drag systems are provided:
+
+- **[Pointer events](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events)** (`makeDraggable`, `makeDroppable`, `createDraggable`, `createDroppable`, `createSortable`, `createDragContext`) — for UI elements moved by the user. The primitives track `pointerdown`/`pointermove`/`pointerup` on the dragged element and document, computing position/collision manually; nothing here uses the browser's native drag-and-drop.
+- **[Native HTML5 Drag and Drop API](https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API)** (`makeNativeDroppable`, `createNativeDroppable`) — for OS file drops and `draggable="true"` elements; used internally by `@solid-primitives/upload`'s `createDropzone`.
+
+## Installation
+
+```bash
+npm install @solid-primitives/drag-drop
+# or
+pnpm add @solid-primitives/drag-drop
+```
+
+## Primitives
+
+### `makeDraggable`
+
+Non-reactive base. Attaches pointer listeners to an element. No Solid owner required.
+
+> **Note:** When pairing with `makeDroppable`, apply `pointer-events: none` to the dragged element during the drag so that `pointerenter`/`pointerleave` can reach the underlying drop targets. The context-based `createDroppable` uses rect collision and is not affected.
+
+```ts
+const cleanup = makeDraggable(el, {
+ onStart: e => {
+ el.style.pointerEvents = "none"; // required for makeDroppable coordination
+ console.log("start", e.clientX);
+ },
+ onMove: delta => (el.style.transform = `translate(${delta.x}px,${delta.y}px)`),
+ onEnd: () => {
+ el.style.pointerEvents = "";
+ el.style.transform = "";
+ },
+});
+// later
+cleanup();
+```
+
+### `makeDroppable`
+
+Non-reactive base. Marks an element as a drop target for pointer-event drags.
+
+> **Note:** Requires `pointer-events: none` on the dragged element during the drag — otherwise the dragged element intercepts `pointerenter`/`pointerleave` before they reach this element.
+
+```ts
+const cleanup = makeDroppable(el, {
+ onEnter: () => el.classList.add("over"),
+ onLeave: () => el.classList.remove("over"),
+ onDrop: () => acceptDrop(),
+});
+```
+
+### `makeNativeDroppable`
+
+Non-reactive base for OS/browser native drag events (e.g. file drops). Handles the `dragenter`/`dragleave` depth problem with child elements automatically.
+
+```ts
+const cleanup = makeNativeDroppable(el, {
+ accept: e => e.dataTransfer?.types.includes("Files") ?? false,
+ onEnter: () => el.classList.add("over"),
+ onDrop: e => handleFiles(e.dataTransfer!.files),
+});
+```
+
+### `createDraggable`
+
+Reactive draggable. Attach via `ref`. Works standalone or inside a `createDragContext` provider.
+
+Uses `setPointerCapture` internally so the drag is never stuck if the pointer leaves the viewport. If the page scrolls mid-drag, `transform` is corrected for the scroll delta so the element stays glued to the pointer instead of drifting.
+
+**Keyboard support:** `ref` sets `tabindex="0"`, `role="button"`, and `aria-roledescription="draggable"` on the element unless you've already set them yourself. With the element focused: `Space`/`Enter` picks up the drag, arrow keys nudge it by `keyboardStep` pixels (default 25), `Space`/`Enter` drops it, and `Escape` cancels.
+
+```tsx
+const drag = createDraggable("card-1", myData, {
+ class: "cursor-grab",
+ draggingClass: "opacity-50 cursor-grabbing",
+ draggingStyle: { boxShadow: "0 8px 24px rgba(0,0,0,.2)" },
+});
+
+
+```
+
+| Option | Type | Description |
+|---|---|---|
+| `style` | `Partial` | Applied to the element on `ref` |
+| `class` | `string` | Added to the element on `ref` |
+| `draggingStyle` | `Partial` | Applied while dragging, removed on drop |
+| `draggingClass` | `string` | Added while dragging, removed on drop |
+| `disabled` | `boolean \| Accessor` | Prevents drag when true |
+| `keyboardStep` | `number` | Pixels moved per arrow-key press while picked up via keyboard. Defaults to 25 |
+
+| Return | Description |
+|---|---|
+| `ref` | Attach to element's `ref` prop |
+| `isDragging` | Accessor — true while a drag is active |
+| `transform` | Accessor — `{ x, y }` delta from drag start, or `null` |
+| `id` | The id passed to the primitive |
+
+### `createDroppable`
+
+Reactive drop target. Requires a `createDragContext` ancestor to coordinate collision detection — using it without one leaves `isOver`/`active` permanently `false`/`null` and logs a dev-mode warning, since it usually means a missing ``.
+
+```tsx
+const drop = createDroppable("zone-1", zoneData, {
+ class: "border-2 border-dashed border-transparent",
+ overClass: "border-indigo-500 bg-indigo-50",
+});
+
+
+ {drop.isOver() ? `release to drop ${drop.active()?.id}` : "drop here"}
+
+```
+
+`accept` is called fresh on every collision check (not memoized), so reading a signal inside it gives you reactive accept logic for free:
+
+```tsx
+const [locked, setLocked] = createSignal(false);
+const drop = createDroppable("zone-1", zoneData, {
+ accept: draggable => !locked() && draggable.data.type === "file",
+});
+```
+
+### `createNativeDroppable`
+
+Reactive native drop zone. Tracks `isOver` state via HTML5 drag events.
+
+```tsx
+const drop = createNativeDroppable({
+ accept: e => e.dataTransfer?.types.includes("Files") ?? false,
+ onDrop: e => handleFiles(e.dataTransfer!.files),
+});
+
+
+ Drop files here
+
+```
+
+### `createDragContext`
+
+Coordinates draggables and droppables. Provide it as a context via `ctx.Provider`.
+
+Droppable rects are snapshotted once at drag start — `getBoundingClientRect` is never called during `pointermove`. Collision checks run at display rate (rAF-throttled) and only write reactive state when the hovered zone actually changes. `createDraggable`'s `isDragging` and `createDroppable`'s `isOver` are backed by `createProjection`, so in a list of many draggables/droppables, a drag start/end or hover change only notifies the specific items whose state actually flipped — not every item in the list.
+
+```tsx
+const ctx = createDragContext({
+ collisionDetection: closestCenter,
+ onDragStart: item => console.log("started", item.id),
+ onDragEnd: (item, over) => console.log("dropped", item.id, "on", over?.id),
+ onDragCancel: item => console.log("cancelled", item.id),
+ autoScroll: true, // or { threshold: 80, speed: 20 }
+ keyboardStep: 25,
+});
+
+
+
+
+
+```
+
+| Option | Type | Description |
+|---|---|---|
+| `collisionDetection` | `CollisionDetector` | Defaults to `pointerWithin` |
+| `onDragStart` / `onDragMove` / `onDragEnd` / `onDragCancel` | callbacks | Drag lifecycle hooks |
+| `keyboardStep` | `number` | Pixels moved per arrow-key press while picked up via keyboard. Defaults to 25 |
+| `autoScroll` | `boolean \| { threshold?, speed? }` | Scrolls the window when the pointer nears a viewport edge during a drag. Off by default; ignored for keyboard-driven drags |
+
+| Return | Description |
+|---|---|
+| `Provider` | Wrap your drag-and-drop tree in this component |
+| `active` | Accessor — the currently dragged `DragItem`, or `null` |
+| `over` | Accessor — the current `DroppableItem` under the draggable, or `null` |
+| `transform` | Accessor — `{ x, y }` delta from drag start, or `null` |
+
+> **Note:** `autoScroll` only scrolls the window — it doesn't track scrolling of an arbitrary nested overflow container.
+
+### `createSortable`
+
+Combines `createDraggable` and `createDroppable` on the same element. When another item is dragged over it, `isActiveDropzone()` is true. The active draggable is never its own drop target.
+
+```tsx
+
+ {item => {
+ const s = createSortable(item.id, item);
+ return (
+
+ {item.label}
+
+ );
+ }}
+
+```
+
+Pair it with `arrayMove` in `onDragEnd` to reorder the backing array — see [`arrayMove`](#arraymove) below.
+
+## Collision detection strategies
+
+All four are exported as pure functions — pass any of them as `collisionDetection` to `createDragContext`.
+
+| Strategy | Description |
+|---|---|
+| `closestCenter` | Nearest droppable by center-to-center distance |
+| `closestCorners` | Nearest droppable by minimum corner-to-pointer distance |
+| `rectIntersection` | Droppable with largest overlap area |
+| `pointerWithin` | Topmost droppable containing the pointer (default) |
+
+```ts
+import { createDragContext, closestCenter } from "@solid-primitives/drag-drop";
+
+const ctx = createDragContext({ collisionDetection: closestCenter });
+```
+
+You can also write a custom detector:
+
+```ts
+import type { CollisionDetector } from "@solid-primitives/drag-drop";
+
+const myDetector: CollisionDetector = (draggable, droppables, pointer) => {
+ // Return the id of the winning droppable, or null
+ return droppables[0]?.id ?? null;
+};
+```
+
+## `arrayMove`
+
+Reorders an array by moving the item at `from` to `to`, returning a new array. Pairs naturally with `createDragContext`'s `onDragEnd` for reordering a `createSortable` list — see the [`createSortable`](#createsortable) example above for the full picture.
+
+```ts
+import { arrayMove } from "@solid-primitives/drag-drop";
+
+const ctx = createDragContext({
+ onDragEnd: (dragged, over) => {
+ if (!over) return;
+ setItems(items => arrayMove(
+ items,
+ items.findIndex(i => i.id === dragged.id),
+ items.findIndex(i => i.id === over.id),
+ ));
+ },
+});
+```
+
+Out-of-range or equal indices return an unmodified copy of the array.
+
+## Integration with `@solid-primitives/upload`
+
+`@solid-primitives/upload`'s `createDropzone` uses `createNativeDroppable` from this package under the hood. No manual wiring is needed — just use `createDropzone` from the upload package directly:
+
+```tsx
+import { createDropzone } from "@solid-primitives/upload";
+
+const { ref, files, isDragging } = createDropzone({
+ onDrop: async files => await upload(files),
+});
+
+
+ Drop files here
+
+```
+
+If you need lower-level control (custom accept filter, access to the raw `DragEvent`, etc.), use `createNativeDroppable` directly:
+
+```tsx
+import { createNativeDroppable } from "@solid-primitives/drag-drop";
+
+const drop = createNativeDroppable({
+ accept: e => e.dataTransfer?.types.includes("Files") ?? false,
+ onDrop: e => processFiles(e.dataTransfer!.files),
+});
+```
+
+## Changelog
+
+See [CHANGELOG.md](./CHANGELOG.md).
diff --git a/packages/drag-drop/deno.jsonc b/packages/drag-drop/deno.jsonc
new file mode 100644
index 000000000..1bd78ed8e
--- /dev/null
+++ b/packages/drag-drop/deno.jsonc
@@ -0,0 +1,24 @@
+{
+ "name": "@solid-primitives/drag-drop",
+ "version": "0.0.100",
+ "description": "Composable drag-and-drop primitives.",
+ "license": "MIT",
+ "exports": "./src/index.ts",
+ "publish": {
+ "include": [
+ "README.md",
+ "LICENSE",
+ "src/**/*.ts",
+ "src/**/*.tsx",
+ "package.json"
+ ],
+ "exclude": [
+ "dist",
+ "dev",
+ "test",
+ "node_modules",
+ "vitest.config.ts",
+ "tsconfig.json"
+ ]
+ }
+}
diff --git a/packages/drag-drop/package.json b/packages/drag-drop/package.json
new file mode 100644
index 000000000..4e9291ac2
--- /dev/null
+++ b/packages/drag-drop/package.json
@@ -0,0 +1,82 @@
+{
+ "name": "@solid-primitives/drag-drop",
+ "version": "0.0.100",
+ "description": "Composable drag-and-drop primitives.",
+ "author": "David Di Biase ",
+ "contributors": [],
+ "license": "MIT",
+ "homepage": "https://primitives.solidjs.community/package/drag-drop",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/solidjs-community/solid-primitives.git"
+ },
+ "bugs": {
+ "url": "https://github.com/solidjs-community/solid-primitives/issues"
+ },
+ "primitive": {
+ "name": "drag-drop",
+ "stage": 0,
+ "list": [
+ "makeDraggable",
+ "makeDroppable",
+ "makeNativeDroppable",
+ "createDraggable",
+ "createDroppable",
+ "createNativeDroppable",
+ "createSortable",
+ "createDragContext",
+ "closestCenter",
+ "closestCorners",
+ "rectIntersection",
+ "pointerWithin"
+ ],
+ "category": "UI Patterns"
+ },
+ "keywords": [
+ "solid",
+ "primitives",
+ "drag",
+ "drop",
+ "dnd",
+ "sortable"
+ ],
+ "private": false,
+ "sideEffects": false,
+ "files": [
+ "dist"
+ ],
+ "type": "module",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "browser": {},
+ "exports": {
+ "import": {
+ "@solid-primitives/source": "./src/index.ts",
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "tsdown": {
+ "entry": "src/**/*.{ts,tsx}",
+ "outDir": "dist"
+ },
+ "typesVersions": {},
+ "scripts": {
+ "dev": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/dev.ts",
+ "build": "pnpm -w build",
+ "vitest": "vitest -c ../../configs/vitest.config.ts",
+ "test": "pnpm run vitest",
+ "test:ssr": "pnpm run vitest --mode ssr"
+ },
+ "dependencies": {
+ "@solid-primitives/utils": "workspace:^"
+ },
+ "devDependencies": {
+ "@solidjs/web": "catalog:",
+ "solid-js": "catalog:"
+ },
+ "peerDependencies": {
+ "@solidjs/web": "catalog:peer",
+ "solid-js": "catalog:peer"
+ }
+}
diff --git a/packages/drag-drop/src/collision.ts b/packages/drag-drop/src/collision.ts
new file mode 100644
index 000000000..2a0821fcb
--- /dev/null
+++ b/packages/drag-drop/src/collision.ts
@@ -0,0 +1,67 @@
+import type { CollisionDetector } from "./types.ts";
+
+/** Returns the droppable whose center is nearest to the pointer. Uses squared distance — no sqrt needed. */
+export const closestCenter: CollisionDetector = (_draggable, droppables, pointer) => {
+ let best: string | number | null = null;
+ let bestDistSq = Infinity;
+ const px = pointer.x, py = pointer.y;
+ for (const d of droppables) {
+ // >> 1 instead of * 0.5: truncates width/height to a 32-bit int and floors the
+ // halving, so the computed center can be off by up to ~1px on fractional rects.
+ const dx = px - (d.rect.left + (d.rect.width >> 1));
+ const dy = py - (d.rect.top + (d.rect.height >> 1));
+ const distSq = dx * dx + dy * dy;
+ if (distSq < bestDistSq) { bestDistSq = distSq; best = d.id; }
+ }
+ return best;
+};
+
+/** Returns the droppable whose nearest corner is closest to the pointer. Uses squared distance — no sqrt needed. */
+export const closestCorners: CollisionDetector = (_draggable, droppables, pointer) => {
+ let best: string | number | null = null;
+ let bestDistSq = Infinity;
+ const px = pointer.x, py = pointer.y;
+ for (const d of droppables) {
+ const { left, right, top, bottom } = d.rect;
+ // Precompute each delta and its square once — each is reused by two of the
+ // four corners below (dxl by top-left & bottom-left, dyt by top-left & top-right, etc.)
+ const dxl = px - left, dxr = px - right, dyt = py - top, dyb = py - bottom;
+ const dxls = dxl * dxl, dxrs = dxr * dxr, dyts = dyt * dyt, dybs = dyb * dyb;
+ let distSq: number;
+ // top-left
+ distSq = dxls + dyts; if (distSq < bestDistSq) { bestDistSq = distSq; best = d.id; }
+ // top-right
+ distSq = dxrs + dyts; if (distSq < bestDistSq) { bestDistSq = distSq; best = d.id; }
+ // bottom-left
+ distSq = dxls + dybs; if (distSq < bestDistSq) { bestDistSq = distSq; best = d.id; }
+ // bottom-right
+ distSq = dxrs + dybs; if (distSq < bestDistSq) { bestDistSq = distSq; best = d.id; }
+ }
+ return best;
+};
+
+/** Returns the droppable with the largest overlap area with the draggable. */
+export const rectIntersection: CollisionDetector = (draggable, droppables) => {
+ const dr = draggable.rect;
+ let best: string | number | null = null;
+ let bestArea = 0;
+ for (const d of droppables) {
+ const r = d.rect;
+ const xOverlap = Math.max(0, Math.min(dr.right, r.right) - Math.max(dr.left, r.left));
+ const yOverlap = Math.max(0, Math.min(dr.bottom, r.bottom) - Math.max(dr.top, r.top));
+ const area = xOverlap * yOverlap;
+ if (area > bestArea) { bestArea = area; best = d.id; }
+ }
+ return best;
+};
+
+/** Returns the topmost droppable whose rect contains the pointer. */
+export const pointerWithin: CollisionDetector = (_draggable, droppables, pointer) => {
+ const px = pointer.x, py = pointer.y;
+ for (let i = droppables.length - 1; i >= 0; i--) {
+ const d = droppables[i]!;
+ const r = d.rect;
+ if (px >= r.left && px <= r.right && py >= r.top && py <= r.bottom) return d.id;
+ }
+ return null;
+};
diff --git a/packages/drag-drop/src/context.tsx b/packages/drag-drop/src/context.tsx
new file mode 100644
index 000000000..17a05c11c
--- /dev/null
+++ b/packages/drag-drop/src/context.tsx
@@ -0,0 +1,347 @@
+import { createContext, createProjection, createSignal, onCleanup, useContext, untrack, type Element } from "solid-js";
+import { isServer } from "@solidjs/web";
+import { INTERNAL_OPTIONS } from "@solid-primitives/utils";
+import { pointerWithin } from "./collision.ts";
+import type {
+ AcceptPredicate,
+ DragContextOptions,
+ DragContextReturn,
+ DragItem,
+ DragRect,
+ DroppableItem,
+ DroppableRect,
+ Transform,
+} from "./types.ts";
+
+const DEFAULT_KEYBOARD_STEP = 25;
+const DEFAULT_AUTO_SCROLL_THRESHOLD = 60;
+const DEFAULT_AUTO_SCROLL_SPEED = 15;
+
+type DroppableEntry = {
+ element: HTMLElement;
+ data: unknown;
+ accept?: AcceptPredicate;
+};
+
+export type DragContextValue = {
+ active: () => DragItem | null;
+ over: () => DroppableItem | null;
+ transform: () => Transform | null;
+ _registerDroppable: (
+ id: string | number,
+ element: HTMLElement,
+ data: unknown,
+ accept?: AcceptPredicate,
+ ) => void;
+ _unregisterDroppable: (id: string | number) => void;
+ _startDrag: (id: string | number, element: HTMLElement, data: unknown, event: PointerEvent) => void;
+ /** Starts a drag from a keyboard activation — anchors the synthetic pointer to the element's center. */
+ _startKeyboardDrag: (id: string | number, element: HTMLElement, data: unknown) => void;
+ /** Nudges the active keyboard-driven drag by a delta in viewport pixels. No-op if nothing is dragging. */
+ _moveBy: (dx: number, dy: number) => void;
+ /** Drops the active drag (equivalent to releasing the pointer). No-op if nothing is dragging. */
+ _endDrag: () => void;
+ /**
+ * `true` iff `id` is the currently active draggable. Backed by `createProjection`, so checking
+ * this for every draggable in a large list only invalidates the (at most two) ids that actually
+ * changed, instead of every instance re-comparing against `active()?.id` on each drag start/end.
+ */
+ _isActive: (id: string | number) => boolean;
+ /** Same fine-grained lookup as `_isActive`, for the currently hovered droppable. */
+ _isOver: (id: string | number) => boolean;
+};
+
+const DragCtx = createContext();
+
+export function useDragContext(): DragContextValue | undefined {
+ try {
+ return useContext(DragCtx);
+ } catch {
+ return undefined;
+ }
+}
+
+export function createDragContext(options: DragContextOptions = {}): DragContextReturn {
+ if (isServer) {
+ const Provider = (props: { children: Element }): Element => props.children;
+ return { Provider, active: () => null, over: () => null, transform: () => null };
+ }
+
+ const droppables = new Map();
+
+ const [active, setActive] = createSignal(null, INTERNAL_OPTIONS);
+ const [over, setOver] = createSignal(null, INTERNAL_OPTIONS);
+ const [transform, setTransform] = createSignal(null, INTERNAL_OPTIONS);
+
+ // Fine-grained "is this id the one" lookups. A plain `active()?.id === id` memo per draggable
+ // would mark every instance in a list as stale on each drag start/end; these projections only
+ // notify the (at most two) ids whose membership actually flipped.
+ let prevActiveId: string | number | undefined;
+ const isActiveId = createProjection>(s => {
+ const id = active()?.id;
+ if (id != null) s[id] = true;
+ if (prevActiveId != null && prevActiveId !== id) delete s[prevActiveId];
+ prevActiveId = id;
+ }, {});
+
+ let prevOverId: string | number | undefined;
+ const isOverId = createProjection>(s => {
+ const id = over()?.id;
+ if (id != null) s[id] = true;
+ if (prevOverId != null && prevOverId !== id) delete s[prevOverId];
+ prevOverId = id;
+ }, {});
+
+ let currentDrag: DragItem | null = null;
+ let startX = 0;
+ let startY = 0;
+ let dragStartScrollX = 0;
+ let dragStartScrollY = 0;
+
+ // Droppable rects snapshotted at drag start — avoids getBoundingClientRect on every pointermove.
+ // Re-snapshotted on scroll or when droppables are added/removed during a drag.
+ const cachedRects: DroppableRect[] = [];
+ let dragStartLeft = 0, dragStartTop = 0, dragStartWidth = 0, dragStartHeight = 0;
+
+ const snapshotRects = (activeId: string | number) => {
+ cachedRects.length = 0;
+ for (const [id, entry] of droppables) {
+ if (id !== activeId) {
+ cachedRects.push({ id, rect: entry.element.getBoundingClientRect() });
+ }
+ }
+ };
+
+ const collide = options.collisionDetection ?? pointerWithin;
+
+ // RAF throttle — buffer latest pointer position, flush to signals at display rate.
+ // `rafPending` guards deduplication so synchronous rAF shims (used in tests) work correctly.
+ let rafPending = false;
+ let rafId = -1;
+ let pendingX = 0;
+ let pendingY = 0;
+
+ // Auto-scrolls the window when the pointer sits near a viewport edge. Only called from the
+ // pointer-driven path — keyboard nudges move a synthetic point that isn't meant to trigger it.
+ const maybeAutoScroll = () => {
+ const cfg = options.autoScroll;
+ if (!cfg) return;
+ const threshold = (typeof cfg === "object" ? cfg.threshold : undefined) ?? DEFAULT_AUTO_SCROLL_THRESHOLD;
+ const speed = (typeof cfg === "object" ? cfg.speed : undefined) ?? DEFAULT_AUTO_SCROLL_SPEED;
+
+ let dx = 0;
+ let dy = 0;
+ if (pendingX < threshold) dx = -speed;
+ else if (pendingX > window.innerWidth - threshold) dx = speed;
+ if (pendingY < threshold) dy = -speed;
+ else if (pendingY > window.innerHeight - threshold) dy = speed;
+
+ if (dx !== 0 || dy !== 0) window.scrollBy(dx, dy);
+ };
+
+ // Recomputes transform + collision state from the current pending pointer position.
+ // Shared by the rAF-throttled pointer path and the immediate keyboard-nudge path.
+ const applyMove = () => {
+ if (!currentDrag) return;
+
+ const tx = pendingX - startX;
+ const ty = pendingY - startY;
+
+ // The reported transform is what the consumer applies as a CSS translate. It needs the
+ // scroll delta added on top of the raw pointer delta — otherwise the element (which scrolls
+ // with the page like any other in-flow content) visually drifts away from the pointer if the
+ // page scrolls mid-drag. The collision rect below intentionally uses the raw (uncompensated)
+ // tx/ty: once the transform above is scroll-corrected, `dragStartLeft + tx` is exactly where
+ // the element will end up on screen.
+ const scrollDX = window.scrollX - dragStartScrollX;
+ const scrollDY = window.scrollY - dragStartScrollY;
+ setTransform({ x: tx + scrollDX, y: ty + scrollDY });
+
+ // Compute draggable rect from initial snapshot + current delta — zero layout reflows during move.
+ const draggableRect: DragRect = {
+ id: currentDrag.id,
+ rect: {
+ left: dragStartLeft + tx,
+ right: dragStartLeft + dragStartWidth + tx,
+ top: dragStartTop + ty,
+ bottom: dragStartTop + dragStartHeight + ty,
+ width: dragStartWidth,
+ height: dragStartHeight,
+ x: dragStartLeft + tx,
+ y: dragStartTop + ty,
+ },
+ };
+
+ const winnerId = collide(draggableRect, cachedRects, { x: pendingX, y: pendingY });
+
+ let winner: DroppableItem | null = null;
+ if (winnerId !== null) {
+ const entry = droppables.get(winnerId);
+ if (entry && !(entry.accept && !entry.accept(currentDrag))) {
+ winner = { id: winnerId, data: entry.data, element: entry.element };
+ }
+ }
+
+ // Only write the signal when the hovered zone actually changes.
+ if (winner?.id !== untrack(over)?.id) setOver(winner);
+
+ options.onDragMove?.(currentDrag, { x: tx, y: ty });
+ };
+
+ const processMove = () => {
+ rafPending = false;
+ applyMove();
+ maybeAutoScroll();
+ };
+
+ const onPointerMove = (event: PointerEvent) => {
+ if (!currentDrag) return;
+ pendingX = event.clientX;
+ pendingY = event.clientY;
+ if (!rafPending) {
+ rafPending = true;
+ rafId = requestAnimationFrame(() => {
+ rafPending = false;
+ processMove();
+ });
+ }
+ };
+
+ const cancelPendingMove = () => {
+ if (rafPending) { cancelAnimationFrame(rafId); rafPending = false; }
+ };
+
+ const finishDrag = () => {
+ if (!currentDrag) return;
+ cancelPendingMove();
+
+ const overItem = untrack(over);
+ const item = currentDrag;
+ currentDrag = null;
+ cleanupDrag();
+ options.onDragEnd?.(item, overItem);
+
+ setActive(null);
+ setOver(null);
+ setTransform(null);
+ };
+
+ const onPointerUp = (_event: PointerEvent) => finishDrag();
+
+ // Shared by Escape (explicit user cancel) and pointercancel (OS/browser-interrupted
+ // drag, e.g. a touch gesture cancelled by the system) — both end the drag without a
+ // drop, so both go through onDragCancel rather than onDragEnd.
+ const cancelDrag = () => {
+ if (!currentDrag) return;
+ cancelPendingMove();
+
+ const item = currentDrag;
+ currentDrag = null;
+ cleanupDrag();
+ options.onDragCancel?.(item);
+
+ setActive(null);
+ setOver(null);
+ setTransform(null);
+ };
+
+ const onPointerCancel = (_event: PointerEvent) => cancelDrag();
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") cancelDrag();
+ };
+
+ const onScroll = () => {
+ if (currentDrag) snapshotRects(currentDrag.id);
+ };
+
+ function cleanupDrag() {
+ document.removeEventListener("pointermove", onPointerMove);
+ document.removeEventListener("pointerup", onPointerUp);
+ document.removeEventListener("pointercancel", onPointerCancel);
+ document.removeEventListener("keydown", onKeyDown);
+ document.removeEventListener("scroll", onScroll, { capture: true });
+ }
+
+ const beginDrag = (
+ id: string | number,
+ element: HTMLElement,
+ data: unknown,
+ clientX: number,
+ clientY: number,
+ ) => {
+ startX = clientX;
+ startY = clientY;
+ pendingX = clientX;
+ pendingY = clientY;
+ dragStartScrollX = window.scrollX;
+ dragStartScrollY = window.scrollY;
+
+ // Snapshot layout once — all pointermove collision checks use this cache.
+ const r = element.getBoundingClientRect();
+ dragStartLeft = r.left;
+ dragStartTop = r.top;
+ dragStartWidth = r.width;
+ dragStartHeight = r.height;
+ snapshotRects(id);
+
+ const item: DragItem = { id, data, element };
+ currentDrag = item;
+ setActive(item);
+ setTransform({ x: 0, y: 0 });
+
+ document.addEventListener("pointermove", onPointerMove);
+ document.addEventListener("pointerup", onPointerUp);
+ document.addEventListener("pointercancel", onPointerCancel);
+ document.addEventListener("keydown", onKeyDown);
+ document.addEventListener("scroll", onScroll, { passive: true, capture: true });
+
+ options.onDragStart?.(item);
+ };
+
+ const _startDrag = (id: string | number, element: HTMLElement, data: unknown, event: PointerEvent) => {
+ beginDrag(id, element, data, event.clientX, event.clientY);
+ };
+
+ const _startKeyboardDrag = (id: string | number, element: HTMLElement, data: unknown) => {
+ const r = element.getBoundingClientRect();
+ beginDrag(id, element, data, r.left + r.width / 2, r.top + r.height / 2);
+ };
+
+ const _moveBy = (dx: number, dy: number) => {
+ if (!currentDrag) return;
+ pendingX += dx;
+ pendingY += dy;
+ applyMove();
+ };
+
+ onCleanup(cleanupDrag);
+
+ const contextValue: DragContextValue = {
+ active,
+ over,
+ transform,
+ _registerDroppable: (id, element, data, accept) => {
+ droppables.set(id, { element, data, accept });
+ if (currentDrag) snapshotRects(currentDrag.id);
+ },
+ _unregisterDroppable: id => {
+ droppables.delete(id);
+ if (currentDrag) snapshotRects(currentDrag.id);
+ },
+ _startDrag,
+ _startKeyboardDrag,
+ _moveBy,
+ _endDrag: finishDrag,
+ _isActive: id => !!isActiveId[id],
+ _isOver: id => !!isOverId[id],
+ };
+
+ const Provider = (props: { children: Element }): Element => (
+ {props.children}
+ );
+
+ return { Provider, active, over, transform };
+}
+
+export { DEFAULT_KEYBOARD_STEP };
diff --git a/packages/drag-drop/src/dom.ts b/packages/drag-drop/src/dom.ts
new file mode 100644
index 000000000..042cc6a57
--- /dev/null
+++ b/packages/drag-drop/src/dom.ts
@@ -0,0 +1,57 @@
+/**
+ * Delta between two client points, compensated for any window scroll that happened
+ * in between. Without this, a pointer-anchored drag transform silently drifts by the
+ * scroll amount if the page scrolls mid-drag (the dragged element moves with the page
+ * like any other in-flow content, but the reported delta wouldn't account for that).
+ */
+export function scrollCompensatedDelta(
+ clientX: number,
+ clientY: number,
+ startX: number,
+ startY: number,
+ startScrollX: number,
+ startScrollY: number,
+): { x: number; y: number } {
+ return {
+ x: clientX - startX + (window.scrollX - startScrollX),
+ y: clientY - startY + (window.scrollY - startScrollY),
+ };
+}
+
+export function applyStyle(el: HTMLElement, style: Partial | undefined): void {
+ if (!style) return;
+ for (const [k, v] of Object.entries(style)) {
+ (el.style as any)[k] = v as string;
+ }
+}
+
+export function removeStyle(el: HTMLElement, style: Partial | undefined): void {
+ if (!style) return;
+ for (const k of Object.keys(style)) {
+ (el.style as any)[k] = "";
+ }
+}
+
+export function applyClass(el: HTMLElement, classes: string | undefined): void {
+ if (!classes) return;
+ for (const cls of classes.split(" ")) {
+ if (cls) el.classList.add(cls);
+ }
+}
+
+export function removeClass(el: HTMLElement, classes: string | undefined): void {
+ if (!classes) return;
+ for (const cls of classes.split(" ")) {
+ if (cls) el.classList.remove(cls);
+ }
+}
+
+/**
+ * Marks an element as a keyboard-reachable drag handle — makes it focusable and
+ * announces its role, without clobbering any of these the consumer already set explicitly.
+ */
+export function markAsDraggable(el: HTMLElement): void {
+ if (!el.hasAttribute("tabindex")) el.tabIndex = 0;
+ if (!el.hasAttribute("role")) el.setAttribute("role", "button");
+ if (!el.hasAttribute("aria-roledescription")) el.setAttribute("aria-roledescription", "draggable");
+}
diff --git a/packages/drag-drop/src/draggable.ts b/packages/drag-drop/src/draggable.ts
new file mode 100644
index 000000000..1db4dd1e7
--- /dev/null
+++ b/packages/drag-drop/src/draggable.ts
@@ -0,0 +1,288 @@
+import { createEffect, createMemo, createSignal, flush } from "solid-js";
+import { isServer } from "@solidjs/web";
+import { access, INTERNAL_OPTIONS, noop } from "@solid-primitives/utils";
+import { DEFAULT_KEYBOARD_STEP, useDragContext } from "./context.tsx";
+import { applyClass, applyStyle, markAsDraggable, removeClass, removeStyle, scrollCompensatedDelta } from "./dom.ts";
+import type { CreateDraggableOptions, DraggableReturn, MakeDraggableOptions, Transform } from "./types.ts";
+
+/**
+ * Attaches pointer-based drag behaviour to an existing element.
+ * Non-reactive — no Solid owner required. Returns a cleanup function.
+ *
+ * Note: `pointerenter`/`pointerleave` on drop targets won't fire reliably
+ * while the dragged element is under the pointer unless you set
+ * `pointer-events: none` on it during the drag. The context-based
+ * `createDroppable` uses rect collision and is not affected.
+ *
+ * @example
+ * ```ts
+ * const cleanup = makeDraggable(el, {
+ * onStart: e => console.log("start", e.clientX),
+ * onMove: delta => el.style.transform = `translate(${delta.x}px,${delta.y}px)`,
+ * onEnd: () => el.style.transform = "",
+ * });
+ * ```
+ */
+export function makeDraggable(
+ el: HTMLElement,
+ options: MakeDraggableOptions = {},
+): VoidFunction {
+ if (isServer) return noop;
+
+ let startX = 0;
+ let startY = 0;
+ let startScrollX = 0;
+ let startScrollY = 0;
+
+ const onPointerMove = (event: PointerEvent) => {
+ const delta = scrollCompensatedDelta(event.clientX, event.clientY, startX, startY, startScrollX, startScrollY);
+ options.onMove?.(delta, event);
+ };
+
+ // Shared by pointerup (normal end) and pointercancel (OS/browser-interrupted drag,
+ // e.g. a touch gesture cancelled by the system) — both must stop tracking and
+ // report an end, or the document-level listeners leak and the caller never learns
+ // the drag stopped.
+ const stopTracking = () => {
+ document.removeEventListener("pointermove", onPointerMove);
+ document.removeEventListener("pointerup", onPointerUp);
+ document.removeEventListener("pointercancel", onPointerCancel);
+ };
+
+ const onPointerUp = (event: PointerEvent) => {
+ stopTracking();
+ const delta = scrollCompensatedDelta(event.clientX, event.clientY, startX, startY, startScrollX, startScrollY);
+ options.onEnd?.(delta, event);
+ };
+
+ const onPointerCancel = (event: PointerEvent) => {
+ stopTracking();
+ const delta = scrollCompensatedDelta(event.clientX, event.clientY, startX, startY, startScrollX, startScrollY);
+ options.onEnd?.(delta, event);
+ };
+
+ const onPointerDown = (event: PointerEvent) => {
+ if (options.disabled) return;
+ if (event.button !== 0) return;
+ event.preventDefault();
+ startX = event.clientX;
+ startY = event.clientY;
+ startScrollX = window.scrollX;
+ startScrollY = window.scrollY;
+ document.addEventListener("pointermove", onPointerMove);
+ document.addEventListener("pointerup", onPointerUp);
+ document.addEventListener("pointercancel", onPointerCancel);
+ options.onStart?.(event);
+ };
+
+ el.addEventListener("pointerdown", onPointerDown);
+
+ return () => {
+ el.removeEventListener("pointerdown", onPointerDown);
+ stopTracking();
+ };
+}
+
+/**
+ * Reactive draggable primitive. Attach to a JSX element via `ref={drag.ref}`.
+ *
+ * When used inside a `createDragContext` Provider, drag state is coordinated
+ * with registered droppables. Without a Provider, `isDragging` and `transform`
+ * still work in standalone mode.
+ *
+ * Keyboard-accessible: the element receives `tabindex`/`role`/`aria-roledescription`
+ * automatically (unless already set). `Space`/`Enter` picks up the drag, arrow keys
+ * nudge it by `keyboardStep` pixels (default 25), `Space`/`Enter` drops it, and
+ * `Escape` cancels — mirroring the pointer sensor's lifecycle.
+ *
+ * @example
+ * ```tsx
+ * const drag = createDraggable("card-1", { title: "My card" }, {
+ * class: "cursor-grab",
+ * draggingClass: "opacity-50 cursor-grabbing",
+ * });
+ *
{drag.isDragging() ? "dragging…" : "drag me"}
+ * ```
+ */
+export function createDraggable(
+ id: string | number,
+ data?: T,
+ options: CreateDraggableOptions = {},
+): DraggableReturn {
+ if (isServer) {
+ return { ref: noop, isDragging: () => false, transform: () => null, id };
+ }
+
+ const ctx = useDragContext();
+ const [elSignal, setElSignal] = createSignal(undefined, INTERNAL_OPTIONS);
+ const step = () => options.keyboardStep ?? DEFAULT_KEYBOARD_STEP;
+
+ let isDragging: () => boolean;
+ let currentTransform: () => Transform | null;
+
+ if (ctx) {
+ // `ctx._isActive` is a createProjection lookup — checking it doesn't mark every other
+ // draggable's `isDragging` stale on each drag start/end, unlike `ctx.active()?.id === id`.
+ isDragging = () => ctx._isActive(id);
+ currentTransform = createMemo(() => (isDragging() ? ctx.transform() : null));
+ } else {
+ const [_isDragging, setIsDragging] = createSignal(false, INTERNAL_OPTIONS);
+ const [_transform, setTransform] = createSignal(null, INTERNAL_OPTIONS);
+ isDragging = _isDragging;
+ currentTransform = _transform;
+
+ let startX = 0;
+ let startY = 0;
+ let startScrollX = 0;
+ let startScrollY = 0;
+
+ const onPointerMove = (event: PointerEvent) => {
+ setTransform(scrollCompensatedDelta(event.clientX, event.clientY, startX, startY, startScrollX, startScrollY));
+ };
+
+ // Shared by pointerup (normal end) and pointercancel (OS/browser-interrupted drag) —
+ // both must stop tracking and reset state, or isDragging/transform get stuck.
+ const stopTracking = () => {
+ document.removeEventListener("pointermove", onPointerMove);
+ document.removeEventListener("pointerup", onPointerUp);
+ document.removeEventListener("pointercancel", onPointerCancel);
+ setIsDragging(false);
+ setTransform(null);
+ };
+
+ const onPointerUp = () => stopTracking();
+ const onPointerCancel = () => stopTracking();
+
+ const nudge = (dx: number, dy: number) => {
+ setTransform(t => ({ x: (t?.x ?? 0) + dx, y: (t?.y ?? 0) + dy }));
+ };
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (access(options.disabled)) return;
+
+ if (!_isDragging()) {
+ if (event.key === " " || event.key === "Enter") {
+ event.preventDefault();
+ setIsDragging(true);
+ setTransform({ x: 0, y: 0 });
+ }
+ return;
+ }
+
+ switch (event.key) {
+ case "ArrowUp": event.preventDefault(); nudge(0, -step()); break;
+ case "ArrowDown": event.preventDefault(); nudge(0, step()); break;
+ case "ArrowLeft": event.preventDefault(); nudge(-step(), 0); break;
+ case "ArrowRight": event.preventDefault(); nudge(step(), 0); break;
+ case " ":
+ case "Enter":
+ case "Escape":
+ event.preventDefault();
+ setIsDragging(false);
+ setTransform(null);
+ break;
+ }
+ };
+
+ createEffect(
+ () => elSignal(),
+ el => {
+ if (!el) return;
+ const onPointerDown = (event: PointerEvent) => {
+ if (access(options.disabled)) return;
+ if (event.button !== 0) return;
+ event.preventDefault();
+ el.setPointerCapture(event.pointerId);
+ startX = event.clientX;
+ startY = event.clientY;
+ startScrollX = window.scrollX;
+ startScrollY = window.scrollY;
+ setIsDragging(true);
+ setTransform({ x: 0, y: 0 });
+ document.addEventListener("pointermove", onPointerMove);
+ document.addEventListener("pointerup", onPointerUp);
+ document.addEventListener("pointercancel", onPointerCancel);
+ };
+ el.addEventListener("pointerdown", onPointerDown);
+ el.addEventListener("keydown", onKeyDown);
+ return () => {
+ el.removeEventListener("pointerdown", onPointerDown);
+ el.removeEventListener("keydown", onKeyDown);
+ stopTracking();
+ };
+ },
+ );
+ }
+
+ // Context mode: attach pointerdown/keydown listeners only (registration handled by droppables).
+ if (ctx) {
+ createEffect(
+ () => elSignal(),
+ el => {
+ if (!el) return;
+ const onPointerDown = (event: PointerEvent) => {
+ if (access(options.disabled)) return;
+ if (event.button !== 0) return;
+ event.preventDefault();
+ el.setPointerCapture(event.pointerId);
+ ctx._startDrag(id, el, data, event);
+ };
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (access(options.disabled)) return;
+
+ if (!isDragging()) {
+ if (event.key === " " || event.key === "Enter") {
+ event.preventDefault();
+ ctx._startKeyboardDrag(id, el, data);
+ }
+ return;
+ }
+
+ switch (event.key) {
+ case "ArrowUp": event.preventDefault(); ctx._moveBy(0, -step()); break;
+ case "ArrowDown": event.preventDefault(); ctx._moveBy(0, step()); break;
+ case "ArrowLeft": event.preventDefault(); ctx._moveBy(-step(), 0); break;
+ case "ArrowRight": event.preventDefault(); ctx._moveBy(step(), 0); break;
+ case " ":
+ case "Enter":
+ event.preventDefault();
+ ctx._endDrag();
+ break;
+ // Escape is handled by the context's own document-level listener.
+ }
+ };
+ el.addEventListener("pointerdown", onPointerDown);
+ el.addEventListener("keydown", onKeyDown);
+ return () => {
+ el.removeEventListener("pointerdown", onPointerDown);
+ el.removeEventListener("keydown", onKeyDown);
+ };
+ },
+ );
+ }
+
+ // Reactive draggingStyle / draggingClass
+ createEffect(
+ () => ({ dragging: isDragging(), el: elSignal() }),
+ ({ dragging, el }) => {
+ if (!el) return;
+ if (dragging) {
+ applyStyle(el, options.draggingStyle);
+ applyClass(el, options.draggingClass);
+ } else {
+ removeStyle(el, options.draggingStyle);
+ removeClass(el, options.draggingClass);
+ }
+ },
+ );
+
+ const ref = (el: HTMLElement) => {
+ setElSignal(() => el);
+ applyStyle(el, options.style);
+ applyClass(el, options.class);
+ markAsDraggable(el);
+ flush();
+ };
+
+ return { ref, isDragging, transform: currentTransform, id };
+}
diff --git a/packages/drag-drop/src/droppable.ts b/packages/drag-drop/src/droppable.ts
new file mode 100644
index 000000000..7175afbcf
--- /dev/null
+++ b/packages/drag-drop/src/droppable.ts
@@ -0,0 +1,318 @@
+import { createEffect, createMemo, createSignal, DEV, flush } from "solid-js";
+import { isServer } from "@solidjs/web";
+import { access, INTERNAL_OPTIONS, noop } from "@solid-primitives/utils";
+import { useDragContext } from "./context.tsx";
+import { applyClass, applyStyle, removeClass, removeStyle } from "./dom.ts";
+import type {
+ CreateDroppableOptions,
+ CreateNativeDroppableOptions,
+ DragItem,
+ DroppableReturn,
+ MakeDroppableOptions,
+ MakeNativeDroppableOptions,
+ NativeDroppableReturn,
+} from "./types.ts";
+
+/**
+ * Attaches pointer-based drop-zone behaviour to an existing element.
+ * Non-reactive — no Solid owner required. Returns a cleanup function.
+ *
+ * Note: coordination with `makeDraggable` requires that the dragging element
+ * has `pointer-events: none` applied during the drag, otherwise
+ * `pointerenter`/`pointerleave` will not reach this element.
+ *
+ * @example
+ * ```ts
+ * const cleanup = makeDroppable(el, {
+ * onEnter: () => el.classList.add("over"),
+ * onLeave: () => el.classList.remove("over"),
+ * onDrop: () => acceptDrop(),
+ * });
+ * ```
+ */
+export function makeDroppable(el: HTMLElement, options: MakeDroppableOptions = {}): VoidFunction {
+ if (isServer) return noop;
+
+ const onPointerEnter = (event: PointerEvent) => {
+ if (options.disabled) return;
+ options.onEnter?.(event);
+ };
+
+ const onPointerLeave = (event: PointerEvent) => {
+ if (options.disabled) return;
+ options.onLeave?.(event);
+ };
+
+ const onPointerUp = (event: PointerEvent) => {
+ if (options.disabled) return;
+ options.onDrop?.(event);
+ };
+
+ el.addEventListener("pointerenter", onPointerEnter);
+ el.addEventListener("pointerleave", onPointerLeave);
+ el.addEventListener("pointerup", onPointerUp);
+
+ return () => {
+ el.removeEventListener("pointerenter", onPointerEnter);
+ el.removeEventListener("pointerleave", onPointerLeave);
+ el.removeEventListener("pointerup", onPointerUp);
+ };
+}
+
+/**
+ * Reactive droppable primitive. Attach to a JSX element via `ref={drop.ref}`.
+ *
+ * Requires a `createDragContext` Provider ancestor to coordinate with draggables.
+ * Without a Provider, `isOver` and `active` remain at their initial values (a dev-mode
+ * warning is logged in that case, since it usually means a missing ``).
+ *
+ * @example
+ * ```tsx
+ * const drop = createDroppable("zone-1", myData, {
+ * class: "border-2 border-dashed border-transparent",
+ * overClass: "border-indigo-500 bg-indigo-50",
+ * });
+ *
{drop.isOver() ? "release to drop" : "drop here"}
+ * ```
+ */
+export function createDroppable(
+ id: string | number,
+ data?: T,
+ options: CreateDroppableOptions = {},
+): DroppableReturn {
+ if (isServer) {
+ return { ref: noop, isOver: () => false, active: () => null, id };
+ }
+
+ const ctx = useDragContext();
+
+ if (DEV && !ctx) {
+ // oxlint-disable-next-line no-console
+ console.warn(
+ "[@solid-primitives/drag-drop] createDroppable/createSortable was used without a " +
+ "createDragContext ancestor — isOver/active will stay false/null. Wrap it in the " +
+ " returned by createDragContext().",
+ );
+ }
+
+ const [elSignal, setElSignal] = createSignal(undefined, INTERNAL_OPTIONS);
+
+ // `ctx._isOver` is a createProjection lookup — checking it doesn't mark every other
+ // droppable's `isOver` stale on each hover change, unlike `ctx.over()?.id === id`.
+ const isOver: () => boolean = ctx ? () => ctx._isOver(id) : () => false;
+
+ const active: () => DragItem | null = ctx
+ ? createMemo((): DragItem | null => (isOver() ? ctx.active() : null))
+ : () => null;
+
+ // Single effect tracks both element and disabled — avoids stale element reads.
+ if (ctx) {
+ createEffect(
+ () => ({ el: elSignal(), disabled: access(options.disabled) }),
+ ({ el, disabled }) => {
+ if (!el || disabled) {
+ ctx._unregisterDroppable(id);
+ return;
+ }
+ ctx._registerDroppable(id, el, data, options.accept);
+ return () => ctx._unregisterDroppable(id);
+ },
+ );
+ }
+
+ // Reactive overStyle / overClass
+ createEffect(
+ () => ({ over: isOver(), el: elSignal() }),
+ ({ over, el }) => {
+ if (!el) return;
+ if (over) {
+ applyStyle(el, options.overStyle);
+ applyClass(el, options.overClass);
+ } else {
+ removeStyle(el, options.overStyle);
+ removeClass(el, options.overClass);
+ }
+ },
+ );
+
+ const ref = (el: HTMLElement) => {
+ setElSignal(() => el);
+ applyStyle(el, options.style);
+ applyClass(el, options.class);
+ flush();
+ };
+
+ return { ref, isOver, active, id };
+}
+
+/**
+ * Attaches native HTML5 drag-event listeners to an existing element.
+ * Designed for OS file drops and `draggable="true"` element drops.
+ * Non-reactive — no Solid owner required. Returns a cleanup function.
+ *
+ * Handles the dragenter/dragleave child-element depth problem internally
+ * so `onEnter` / `onLeave` fire exactly once per zone entry/exit.
+ *
+ * @example
+ * ```ts
+ * const cleanup = makeNativeDroppable(el, {
+ * accept: e => e.dataTransfer?.types.includes("Files") ?? false,
+ * onEnter: () => el.classList.add("over"),
+ * onLeave: () => el.classList.remove("over"),
+ * onDrop: e => handleFiles(e.dataTransfer!.files),
+ * });
+ * ```
+ */
+export function makeNativeDroppable(
+ el: HTMLElement,
+ options: MakeNativeDroppableOptions = {},
+): VoidFunction {
+ if (isServer) return noop;
+
+ // Depth counter handles dragenter/dragleave from child elements
+ let depth = 0;
+
+ const onDragEnter = (event: DragEvent) => {
+ if (options.disabled) return;
+ if (options.accept && !options.accept(event)) {
+ event.dataTransfer && (event.dataTransfer.dropEffect = "none");
+ return;
+ }
+ depth++;
+ if (depth === 1) options.onEnter?.(event);
+ };
+
+ const onDragLeave = (event: DragEvent) => {
+ if (options.disabled) return;
+ if (options.accept && !options.accept(event)) return;
+ if (depth > 0) depth--;
+ if (depth === 0) options.onLeave?.(event);
+ };
+
+ const onDragOver = (event: DragEvent) => {
+ event.preventDefault();
+ if (options.disabled) return;
+ if (options.accept && !options.accept(event)) {
+ event.dataTransfer && (event.dataTransfer.dropEffect = "none");
+ return;
+ }
+ options.onOver?.(event);
+ };
+
+ const onDrop = (event: DragEvent) => {
+ event.preventDefault();
+ depth = 0;
+ if (options.disabled) return;
+ if (options.accept && !options.accept(event)) return;
+ options.onDrop?.(event);
+ };
+
+ el.addEventListener("dragenter", onDragEnter);
+ el.addEventListener("dragleave", onDragLeave);
+ el.addEventListener("dragover", onDragOver);
+ el.addEventListener("drop", onDrop);
+
+ return () => {
+ el.removeEventListener("dragenter", onDragEnter);
+ el.removeEventListener("dragleave", onDragLeave);
+ el.removeEventListener("dragover", onDragOver);
+ el.removeEventListener("drop", onDrop);
+ };
+}
+
+/**
+ * Reactive native drop-zone primitive. Attach to a JSX element via `ref={drop.ref}`.
+ * Use this as the foundation for file-upload drop zones (see `createDropzone` in
+ * the upload package) or any element that accepts OS / browser-native drag payloads.
+ *
+ * @example
+ * ```tsx
+ * const drop = createNativeDroppable({
+ * accept: e => e.dataTransfer?.types.includes("Files") ?? false,
+ * onDrop: e => handleFiles(e.dataTransfer!.files),
+ * });
+ *
+ * Drop files here
+ *
+ * ```
+ */
+export function createNativeDroppable(
+ options: CreateNativeDroppableOptions = {},
+): NativeDroppableReturn {
+ if (isServer) {
+ return { ref: noop, isOver: () => false };
+ }
+
+ const [elSignal, setElSignal] = createSignal(undefined, INTERNAL_OPTIONS);
+ const [isOver, setIsOver] = createSignal(false, INTERNAL_OPTIONS);
+
+ createEffect(
+ () => elSignal(),
+ el => {
+ if (!el) return;
+
+ let depth = 0;
+
+ const onDragEnter = (event: DragEvent) => {
+ if (access(options.disabled)) return;
+ if (options.accept && !options.accept(event)) {
+ event.dataTransfer && (event.dataTransfer.dropEffect = "none");
+ return;
+ }
+ depth++;
+ if (depth === 1) {
+ setIsOver(true);
+ options.onEnter?.(event);
+ }
+ };
+
+ const onDragLeave = (event: DragEvent) => {
+ if (access(options.disabled)) return;
+ if (options.accept && !options.accept(event)) return;
+ if (depth > 0) depth--;
+ if (depth === 0) {
+ setIsOver(false);
+ options.onLeave?.(event);
+ }
+ };
+
+ const onDragOver = (event: DragEvent) => {
+ event.preventDefault();
+ if (access(options.disabled)) return;
+ if (options.accept && !options.accept(event)) {
+ event.dataTransfer && (event.dataTransfer.dropEffect = "none");
+ return;
+ }
+ options.onOver?.(event);
+ };
+
+ const onDrop = (event: DragEvent) => {
+ event.preventDefault();
+ depth = 0;
+ setIsOver(false);
+ if (access(options.disabled)) return;
+ if (options.accept && !options.accept(event)) return;
+ options.onDrop?.(event);
+ };
+
+ el.addEventListener("dragenter", onDragEnter);
+ el.addEventListener("dragleave", onDragLeave);
+ el.addEventListener("dragover", onDragOver);
+ el.addEventListener("drop", onDrop);
+
+ return () => {
+ el.removeEventListener("dragenter", onDragEnter);
+ el.removeEventListener("dragleave", onDragLeave);
+ el.removeEventListener("dragover", onDragOver);
+ el.removeEventListener("drop", onDrop);
+ };
+ },
+ );
+
+ const ref = (el: HTMLElement) => {
+ setElSignal(() => el);
+ flush();
+ };
+
+ return { ref, isOver };
+}
diff --git a/packages/drag-drop/src/index.ts b/packages/drag-drop/src/index.ts
new file mode 100644
index 000000000..5b35fc216
--- /dev/null
+++ b/packages/drag-drop/src/index.ts
@@ -0,0 +1,38 @@
+export type {
+ Transform,
+ Point,
+ Rect,
+ DragItem,
+ DroppableItem,
+ DragRect,
+ DroppableRect,
+ CollisionDetector,
+ AcceptPredicate,
+ MakeDraggableOptions,
+ MakeDroppableOptions,
+ MakeNativeDroppableOptions,
+ CreateDraggableOptions,
+ CreateDroppableOptions,
+ CreateNativeDroppableOptions,
+ DraggableReturn,
+ DroppableReturn,
+ NativeDroppableReturn,
+ SortableReturn,
+ DragContextOptions,
+ DragContextReturn,
+} from "./types.ts";
+
+export { closestCenter, closestCorners, rectIntersection, pointerWithin } from "./collision.ts";
+
+export { createDragContext } from "./context.tsx";
+
+export { makeDraggable, createDraggable } from "./draggable.ts";
+
+export {
+ makeDroppable,
+ createDroppable,
+ makeNativeDroppable,
+ createNativeDroppable,
+} from "./droppable.ts";
+
+export { createSortable, arrayMove } from "./sortable.ts";
diff --git a/packages/drag-drop/src/sortable.ts b/packages/drag-drop/src/sortable.ts
new file mode 100644
index 000000000..c6e8da587
--- /dev/null
+++ b/packages/drag-drop/src/sortable.ts
@@ -0,0 +1,102 @@
+import { isServer } from "@solidjs/web";
+import { noop } from "@solid-primitives/utils";
+import { createDraggable } from "./draggable.ts";
+import { createDroppable } from "./droppable.ts";
+import type { SortableReturn } from "./types.ts";
+
+/**
+ * Reorders an array by moving the item at `from` to `to`, returning a new array —
+ * the array itself and the item order at both indices are left untouched if either
+ * index is out of range or they're equal. Pairs naturally with `createDragContext`'s
+ * `onDragEnd` for reordering a `createSortable` list.
+ *
+ * @example
+ * ```ts
+ * const ctx = createDragContext({
+ * onDragEnd: (dragged, over) => {
+ * if (!over) return;
+ * setItems(items => arrayMove(
+ * items,
+ * items.findIndex(i => i.id === dragged.id),
+ * items.findIndex(i => i.id === over.id),
+ * ));
+ * },
+ * });
+ * ```
+ */
+export function arrayMove(array: readonly T[], from: number, to: number): T[] {
+ const next = array.slice();
+ if (
+ !Number.isInteger(from) ||
+ !Number.isInteger(to) ||
+ from < 0 ||
+ from >= array.length ||
+ to < 0 ||
+ to >= array.length ||
+ from === to
+ ) {
+ return next;
+ }
+ const [moved] = next.splice(from, 1);
+ next.splice(to, 0, moved as T);
+ return next;
+}
+
+/**
+ * Combines `createDraggable` and `createDroppable` on the same element.
+ * The element can both be dragged and serve as a drop target for other items.
+ *
+ * When inside a `createDragContext`, a sortable never registers as its own
+ * drop target — the context filters out the active draggable from collision
+ * detection, so `isOver` is always false while this item is being dragged.
+ *
+ * @example
+ * ```tsx
+ * const [items, setItems] = createSignal(["A", "B", "C"]);
+ *
+ *
+ * {(item) => {
+ * const sortable = createSortable(item);
+ * return (
+ *
+ * {item}
+ *
+ * );
+ * }}
+ *
+ * ```
+ */
+export function createSortable(
+ id: string | number,
+ data?: T,
+): SortableReturn {
+ if (isServer) {
+ return {
+ ref: noop,
+ isDragging: () => false,
+ transform: () => null,
+ isOver: () => false,
+ active: () => null,
+ isActiveDropzone: () => false,
+ id,
+ };
+ }
+
+ const draggable = createDraggable(id, data);
+ const droppable = createDroppable(id, data);
+
+ const ref = (el: HTMLElement) => {
+ draggable.ref(el);
+ droppable.ref(el);
+ };
+
+ return {
+ ref,
+ isDragging: draggable.isDragging,
+ transform: draggable.transform,
+ isOver: droppable.isOver,
+ active: droppable.active,
+ isActiveDropzone: droppable.isOver,
+ id,
+ };
+}
diff --git a/packages/drag-drop/src/types.ts b/packages/drag-drop/src/types.ts
new file mode 100644
index 000000000..8314b0d16
--- /dev/null
+++ b/packages/drag-drop/src/types.ts
@@ -0,0 +1,138 @@
+import type { Accessor, Element } from "solid-js";
+
+export type Transform = { x: number; y: number };
+export type Point = { x: number; y: number };
+
+export type DragItem = {
+ id: string | number;
+ data: T;
+ element: HTMLElement;
+};
+
+export type DroppableItem = {
+ id: string | number;
+ data: T;
+ element: HTMLElement;
+};
+
+export type Rect = {
+ left: number;
+ right: number;
+ top: number;
+ bottom: number;
+ width: number;
+ height: number;
+ x: number;
+ y: number;
+};
+export type DragRect = { id: string | number; rect: Rect };
+export type DroppableRect = { id: string | number; rect: Rect };
+
+export type CollisionDetector = (
+ draggable: DragRect,
+ droppables: DroppableRect[],
+ pointer: Point,
+) => string | number | null;
+
+export type MakeDraggableOptions = {
+ data?: T;
+ onStart?: (event: PointerEvent) => void;
+ onMove?: (delta: Transform, event: PointerEvent) => void;
+ onEnd?: (delta: Transform, event: PointerEvent) => void;
+ disabled?: boolean;
+};
+
+export type MakeDroppableOptions = {
+ onEnter?: (event: PointerEvent) => void;
+ onLeave?: (event: PointerEvent) => void;
+ onDrop?: (event: PointerEvent) => void;
+ disabled?: boolean;
+};
+
+export type MakeNativeDroppableOptions = {
+ onEnter?: (event: DragEvent) => void;
+ onLeave?: (event: DragEvent) => void;
+ onOver?: (event: DragEvent) => void;
+ onDrop?: (event: DragEvent) => void;
+ /** Return false to reject; checked on dragenter, dragover, and drop. */
+ accept?: (event: DragEvent) => boolean;
+ disabled?: boolean;
+};
+
+export type CreateDraggableOptions = {
+ disabled?: boolean | Accessor;
+ style?: Partial;
+ draggingStyle?: Partial;
+ class?: string;
+ draggingClass?: string;
+ /** Pixels moved per arrow-key press while picked up via keyboard. Defaults to 25. */
+ keyboardStep?: number;
+};
+
+export type AcceptPredicate = (draggable: DragItem) => boolean;
+
+export type CreateDroppableOptions = {
+ disabled?: boolean | Accessor;
+ /** Called fresh on every collision check — read a signal inside it for reactive accept logic. */
+ accept?: AcceptPredicate;
+ style?: Partial;
+ overStyle?: Partial;
+ class?: string;
+ overClass?: string;
+};
+
+export type CreateNativeDroppableOptions = MakeNativeDroppableOptions & {
+ disabled?: boolean | Accessor;
+};
+
+export type DraggableReturn<_T = unknown> = {
+ ref: (el: HTMLElement) => void;
+ isDragging: Accessor;
+ transform: Accessor;
+ id: string | number;
+};
+
+export type DroppableReturn<_T = unknown> = {
+ ref: (el: HTMLElement) => void;
+ isOver: Accessor;
+ active: Accessor;
+ id: string | number;
+};
+
+export type NativeDroppableReturn = {
+ ref: (el: HTMLElement) => void;
+ isOver: Accessor;
+};
+
+export type SortableReturn<_T = unknown> = {
+ ref: (el: HTMLElement) => void;
+ isDragging: Accessor;
+ transform: Accessor;
+ isOver: Accessor;
+ active: Accessor;
+ isActiveDropzone: Accessor;
+ id: string | number;
+};
+
+export type DragContextOptions = {
+ collisionDetection?: CollisionDetector;
+ onDragStart?: (item: DragItem) => void;
+ onDragMove?: (item: DragItem, transform: Transform) => void;
+ onDragEnd?: (item: DragItem, over: DroppableItem | null) => void;
+ onDragCancel?: (item: DragItem) => void;
+ /** Pixels moved per arrow-key press while picked up via keyboard. Defaults to 25. */
+ keyboardStep?: number;
+ /**
+ * Auto-scroll the window when the pointer nears the viewport edge during a drag.
+ * `true` uses the default threshold/speed; pass an object to tune them, or omit /
+ * `false` to disable. Ignored for keyboard-driven drags.
+ */
+ autoScroll?: boolean | { threshold?: number; speed?: number };
+};
+
+export type DragContextReturn = {
+ Provider: (props: { children: Element }) => Element;
+ active: Accessor;
+ over: Accessor;
+ transform: Accessor;
+};
diff --git a/packages/drag-drop/stories/drag-drop.stories.tsx b/packages/drag-drop/stories/drag-drop.stories.tsx
new file mode 100644
index 000000000..2144cc4b7
--- /dev/null
+++ b/packages/drag-drop/stories/drag-drop.stories.tsx
@@ -0,0 +1,490 @@
+import { createMemo, createSignal, For } from "solid-js";
+import preview from "../../../.storybook/preview.js";
+import readme from "../README.md?raw";
+import {
+ BoolRow,
+ Button,
+ ButtonRow,
+ Card,
+ Container,
+ StatRow,
+} from "../../../.storybook/ui/index.js";
+import {
+ createDraggable,
+ createDroppable,
+ createDragContext,
+ createNativeDroppable,
+ createSortable,
+ closestCenter,
+ closestCorners,
+ rectIntersection,
+ pointerWithin,
+} from "@solid-primitives/drag-drop";
+import type { CollisionDetector } from "@solid-primitives/drag-drop";
+
+const meta = preview.meta({
+ title: "UI Patterns/Drag & Drop",
+ tags: ["autodocs"],
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component: readme,
+ },
+ },
+ },
+});
+
+export default meta;
+
+// ── Story 1: createDraggable standalone ───────────────────────────────────────
+
+export const StandaloneDrag = meta.story({
+ name: "Floating card",
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ story:
+ "`createDraggable` tracks pointer position and exposes `isDragging` and `transform`. Apply the transform in JSX to move the element. `draggingStyle` and `draggingClass` are applied automatically by the primitive while dragging — no manual effect needed.",
+ },
+ },
+ },
+ render: () => {
+ const drag = createDraggable("card", undefined, {
+ draggingStyle: {
+ boxShadow: "0 20px 48px rgba(0,0,0,.18)",
+ opacity: "0.92",
+ },
+ });
+
+ return (
+
+
+
+ {drag.isDragging() ? "Dragging…" : "Drag me"}
+
+
+
+
+
+
+
+
+
+
+ The card springs back when released. draggingStyle adds the shadow
+ automatically — the primitive applies and removes it without any manual effect.
+
+
+ );
+ },
+});
+
+// ── Story 2: createDragContext ────────────────────────────────────────────────
+
+type Strategy = { label: string; fn: CollisionDetector };
+const STRATEGIES: Strategy[] = [
+ { label: "pointerWithin", fn: pointerWithin },
+ { label: "closestCenter", fn: closestCenter },
+ { label: "closestCorners", fn: closestCorners },
+ { label: "rectIntersection", fn: rectIntersection },
+];
+
+const ZONES = [
+ { id: "red", label: "Red zone", bg: "#fee2e2", active: "#ef4444" },
+ { id: "green", label: "Green zone", bg: "#dcfce7", active: "#22c55e" },
+ { id: "blue", label: "Blue zone", bg: "#dbeafe", active: "#3b82f6" },
+];
+
+export const DragAndDrop = meta.story({
+ name: "Drop zones with collision strategies",
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ story:
+ "`createDragContext` coordinates a tree of draggables and droppables via a shared Provider. Collision detection resolves which droppable is active — swap strategies with the buttons below to see how each behaves.",
+ },
+ },
+ },
+ render: () => {
+ const [strategyIdx, setStrategyIdx] = createSignal(0);
+ const [lastDrop, setLastDrop] = createSignal(null);
+
+ const ctx = createDragContext({
+ collisionDetection: (...args) => STRATEGIES[strategyIdx()]!.fn(...args),
+ onDragEnd: (item, over) =>
+ setLastDrop(over ? `"${item.id}" → "${over.id}"` : `"${item.id}" → (none)`),
+ });
+
+ // createDraggable and createDroppable must be called inside ctx.Provider's
+ // children so useDragContext() finds the provider in the reactive owner chain.
+ function DemoContent() {
+ const drag = createDraggable("chip", undefined, {
+ draggingStyle: { boxShadow: "0 12px 32px rgba(0,0,0,.15)", opacity: "0.9" },
+ });
+
+ return (
+ <>
+ {/* Drop zones */}
+