A lightweight, flexible drag and drop library for Svelte 5 applications. Built with TypeScript and Svelte's runes system for maximum performance and developer experience.
Docs & community: Contributing · Security · Changelog · Code of Conduct
- Svelte 5 Native — Built on Svelte's
$staterunes for reactive state management - Dual-Mode Drag — HTML5 Drag API for desktop + Pointer Events for touch/mobile support
- TypeScript-First — Full type safety with generics support throughout
- Flexible Layouts — Vertical, horizontal, and grid layouts with smart drop indicators
- Drag Handles — Optional handle selectors for precise drag control
- Smart Interaction — Automatically protects interactive elements (inputs, buttons, etc.)
- Drop Indicators — Visual feedback showing exactly where items will drop
- Nested Support — Works with nested containers and complex hierarchies
- Attachments — First-class
{@attach}factories for components (Svelte 5.29+) - Keyboard — Opt-in Space/arrows/Escape reordering with screen-reader announcements
- Lightweight — Minimal footprint with zero external dependencies
npm install @thisux/sveltednd
# or
bun add @thisux/sveltednd
# or
yarn add @thisux/sveltednd
# or
pnpm add @thisux/sveltedndCoding agents can install the SvelteDnD skill for API patterns, reorder recipes, attachments, and keyboard guidance:
npx skills add thisuxhq/sveltednd
# or a single skill
npx skills add thisuxhq/sveltednd --skill sveltedndSkill source: skills/sveltednd/. Directory: skills.sh/thisuxhq/sveltednd.
<script lang="ts">
import { draggable, droppable, type DragDropState } from '@thisux/sveltednd';
let items = $state(['Item 1', 'Item 2', 'Item 3']);
function handleDrop(state: DragDropState<string>) {
const { draggedItem, sourceContainer, targetContainer, dropPosition } = state;
// Simple reordering logic
const dragIndex = items.indexOf(draggedItem);
let dropIndex = parseInt(targetContainer ?? '0');
if (dropPosition === 'after') dropIndex++;
if (dragIndex !== -1) {
const [item] = items.splice(dragIndex, 1);
const adjusted = dragIndex < dropIndex ? dropIndex - 1 : dropIndex;
items.splice(adjusted, 0, item);
}
}
</script>
{#each items as item, index (item)}
<div
use:draggable={{ container: index.toString(), dragData: item }}
use:droppable={{ container: index.toString(), callbacks: { onDrop: handleDrop } }}
>
{item}
</div>
{/each}Make any element draggable with the draggable action:
<div use:draggable={{ container: 'my-list', dragData: item }}>
{item.name}
</div>Create drop zones with the droppable action:
<div use:droppable={{ container: 'my-list', callbacks: { onDrop: handleDrop } }}>
<!-- Draggable items go here -->
</div>Access real-time drag state anywhere in your app:
<script>
import { dndState } from '@thisux/sveltednd';
</script>
{#if dndState.isDragging}
<p>Dragging {dndState.draggedItem?.name} from {dndState.sourceContainer}</p>
{/if}| Property | Type | Description |
|---|---|---|
container |
string |
Required. Container identifier for grouping items |
dragData |
T |
Required. Data payload to transfer during drag |
disabled |
boolean |
Disable dragging for this element |
handle |
string |
CSS selector for drag handle (e.g., '.drag-handle') |
interactive |
string[] |
Additional selectors for interactive elements that shouldn't trigger drag |
callbacks |
object |
Event callbacks (onDragStart, onDragEnd) |
attributes |
object |
CSS class overrides (draggingClass) |
| Property | Type | Description |
|---|---|---|
container |
string |
Required. Container identifier |
disabled |
boolean |
Disable dropping for this element |
direction |
'vertical' | 'horizontal' | 'grid' |
Layout direction (default: 'vertical') |
callbacks |
object |
Event callbacks (onDragEnter, onDragLeave, onDragOver, onDrop) |
attributes |
object |
CSS class overrides (dragOverClass) |
interface DragDropState<T = unknown> {
isDragging: boolean; // Currently dragging?
draggedItem: T; // Item being dragged
sourceContainer: string; // Origin container ID
targetContainer: string | null; // Current drop target
targetElement: HTMLElement | null; // Element under cursor
dropPosition: 'before' | 'after' | null; // Where item will drop
invalidDrop?: boolean; // Over invalid drop zone?
}interface DragDropCallbacks<T = unknown> {
onDragStart?: (state: DragDropState<T>) => void;
onDragEnd?: (state: DragDropState<T>) => void;
onDragEnter?: (state: DragDropState<T>) => void;
onDragLeave?: (state: DragDropState<T>) => void;
onDragOver?: (state: DragDropState<T>) => void;
onDrop?: (state: DragDropState<T>) => Promise<void> | void;
}<script lang="ts">
import { draggable, droppable, type DragDropState } from '@thisux/sveltednd';
interface Task {
id: string;
title: string;
}
let tasks = $state<Task[]>([
{ id: '1', title: 'Design review' },
{ id: '2', title: 'Code review' },
{ id: '3', title: 'Deploy to prod' }
]);
function handleDrop(state: DragDropState<Task>) {
const { draggedItem, targetContainer, dropPosition } = state;
const dragIndex = tasks.findIndex((t) => t.id === draggedItem.id);
let dropIndex = parseInt(targetContainer ?? '0');
if (dropPosition === 'after') dropIndex++;
if (dragIndex !== -1) {
const [task] = tasks.splice(dragIndex, 1);
const adjusted = dragIndex < dropIndex ? dropIndex - 1 : dropIndex;
tasks.splice(adjusted, 0, task);
}
}
</script>
<div class="task-list">
{#each tasks as task, index (task.id)}
<div
use:draggable={{ container: index.toString(), dragData: task }}
use:droppable={{ container: index.toString(), callbacks: { onDrop: handleDrop } }}
class="task-item"
>
{task.title}
</div>
{/each}
</div><script lang="ts">
import { draggable, droppable, type DragDropState } from '@thisux/sveltednd';
interface Card {
id: string;
title: string;
status: 'todo' | 'in-progress' | 'done';
}
let cards = $state<Card[]>([
{ id: '1', title: 'Task A', status: 'todo' },
{ id: '2', title: 'Task B', status: 'in-progress' },
{ id: '3', title: 'Task C', status: 'done' }
]);
const columns = ['todo', 'in-progress', 'done'] as const;
function handleDrop(state: DragDropState<Card>) {
const { draggedItem, targetContainer } = state;
if (!targetContainer) return;
cards = cards.map((c) =>
c.id === draggedItem.id ? { ...c, status: targetContainer as Card['status'] } : c
);
}
</script>
<div class="board">
{#each columns as column}
<div use:droppable={{ container: column, callbacks: { onDrop: handleDrop } }} class="column">
<h3>{column}</h3>
{#each cards.filter((c) => c.status === column) as card (card.id)}
<div use:draggable={{ container: column, dragData: card }} class="card">
{card.title}
</div>
{/each}
</div>
{/each}
</div>Use the handle option to restrict dragging to a specific element:
<div
use:draggable={{
container: 'items',
dragData: item,
handle: '.drag-handle' // Only the grip icon starts dragging
}}
>
<span class="drag-handle">⋮⋮</span>
<span>{item.name}</span>
<!-- Text remains selectable, buttons remain clickable -->
</div>Override default styling with custom classes:
<div
use:draggable={{
container: 'list',
dragData: item,
attributes: { draggingClass: 'my-dragging opacity-50' }
}}
use:droppable={{
container: 'list',
callbacks: { onDrop: handleDrop },
attributes: { dragOverClass: 'my-dropzone bg-blue-100' }
}}
>
{item}
</div>For grid layouts, the library uses nearest-edge detection:
<div class="grid grid-cols-3 gap-4">
{#each items as item, index (item.id)}
<div
use:draggable={{ container: index.toString(), dragData: item }}
use:droppable={{
container: index.toString(),
direction: 'grid',
callbacks: { onDrop: handleDrop }
}}
>
{item.name}
</div>
{/each}
</div><div class="flex gap-4">
{#each items as item, index (item.id)}
<div
use:draggable={{ container: index.toString(), dragData: item }}
use:droppable={{
container: index.toString(),
direction: 'horizontal',
callbacks: { onDrop: handleDrop }
}}
>
{item}
</div>
{/each}
</div>Control which items can be dropped where:
<script lang="ts">
function handleDragOver(state: DragDropState<Item>) {
// Mark invalid drops
dndState.invalidDrop = !isValidDrop(state.draggedItem, state.targetContainer);
}
function handleDrop(state: DragDropState<Item>) {
if (dndState.invalidDrop) return;
// Process the drop
}
</script>
<div
use:droppable={{
container: 'filtered',
callbacks: {
onDragOver: handleDragOver,
onDrop: handleDrop
}
}}
>
<!-- Items -->
</div>Drop callbacks support async operations:
<div
use:droppable={{
container: 'list',
callbacks: {
onDrop: async (state) => {
await saveToDatabase(state.draggedItem);
await refreshData();
}
}
}}
>
<!-- Items -->
</div>The library provides these default classes (all customizable):
/* Applied while dragging */
.dragging {
opacity: 0.5;
}
/* Applied when dragged over */
.drag-over {
outline: 2px dashed #4caf50;
}
/* Drop position indicators */
.drop-before::before, /* Line above element */
.drop-after::after, /* Line below element */
.drop-left::before, /* Line to left (horizontal) */
.drop-right::after {
/* Line to right (horizontal) */
content: '';
position: absolute;
background-color: #3b82f6;
pointer-events: none;
}Explore the demo pages for complete working examples:
- Kanban Board — Full-featured board with multiple columns
- Simple Sortable List — Basic reordering
- Grid Sort — 2D grid reordering
- Horizontal Scroll — Horizontal lists
- Nested Containers — Hierarchical drag and drop
- Drag Handles — Handle-based dragging
- Custom Classes — Custom styling
- Interactive Elements — Forms inside draggable items
- Conditional Check — Validation before drop
- Attachments —
{@attach}on components - Keyboard — Space / arrows / Escape reordering
Enable keyboard reordering with keyboard: true on a draggable. Mouse and touch
behavior are unchanged when the option is omitted.
| Key | Action |
|---|---|
| Tab | Focus a keyboard-enabled item |
| Space / Enter | Pick up, or drop while dragging |
| ↑ / ↓ (or ← / → for horizontal) | Move the drop preview among registered drop zones |
| Escape | Cancel without calling onDrop |
<script lang="ts">
import { draggable, droppable, type DragDropState } from '@thisux/sveltednd';
let items = $state(['Alpha', 'Beta', 'Gamma']);
function handleDrop(state: DragDropState<string>) {
const from = items.indexOf(state.draggedItem);
let to = parseInt(state.targetContainer ?? '0');
if (state.dropPosition === 'after') to += 1;
if (from === -1) return;
const next = [...items];
const [item] = next.splice(from, 1);
next.splice(from < to ? to - 1 : to, 0, item);
items = next;
}
</script>
{#each items as item, index (item)}
<div
use:draggable={{
container: index.toString(),
dragData: item,
keyboard: true
}}
use:droppable={{
container: index.toString(),
callbacks: { onDrop: handleDrop }
}}
>
{item}
</div>
{/each}Notes:
- Keyboard uses the same
onDropcontract as pointer/HTML5 — your data model stays in the app. - An assertive live region announces grab / move / drop / cancel for screen readers.
- Interactive children (inputs, buttons, …) still receive Space/Enter normally.
- Cross-column Kanban keyboard navigation is planned as a follow-up (
navigation: 'containers').
Svelte actions (use:draggable, use:droppable) only work on native HTML elements. For
components, use the first-class attachment factories:
| API | Use on | Syntax |
|---|---|---|
draggable / droppable |
HTML elements | use:draggable={...} |
attachDraggable / attachDroppable |
Elements or components | {@attach attachDraggable(...)} |
Pass a getter (() => options) when options depend on reactive state — that keeps
generics intact and updates the underlying action without remounting.
<script lang="ts">
import { attachDraggable, attachDroppable, type DragDropState } from '@thisux/sveltednd';
interface Task {
id: string;
title: string;
}
let task = $state<Task>({ id: '1', title: 'Ship attach API' });
function handleDrop(state: DragDropState<Task>) {
// update your data
}
</script>
<!-- Component roots: works because Card/Column spread {...props} onto an element -->
<Card
{@attach attachDraggable(() => ({
container: 'list',
dragData: task
}))}
>
{task.title}
</Card>
<Column
{@attach attachDroppable<Task>(() => ({
container: 'todo',
callbacks: { onDrop: handleDrop }
}))}
>
<!-- cards -->
</Column>Your component must forward props (and thus attachments) to a real DOM node:
<!-- Card.svelte -->
<script lang="ts">
let { children, ...props } = $props();
</script>
<div {...props}>
{@render children?.()}
</div>You can still use fromAction from svelte/attachments with the raw actions if you prefer,
but always pass a function as the second argument:
<!-- Correct -->
<div {@attach fromAction(draggable, () => ({ container: 'list', dragData: item }))}></div>
<!-- Incorrect — second arg must be a getter, not the options object -->
<div {@attach fromAction(draggable, { container: 'list', dragData: item })}></div>Note: Attachments require Svelte 5.29+ (
fromActionis available in current 5.x). On older versions, wrap the component in a<div>and useuse:draggable/use:droppable.
Live demo: Attachments · source
- Chrome/Edge 88+
- Firefox 78+
- Safari 14+
- Mobile Safari (iOS 14+)
- Chrome for Android
- Svelte 5.0 or higher
- TypeScript 5.0 or higher (optional but recommended)
We welcome contributions! Please see CONTRIBUTING.md for setup, coding style, Conventional Commits, and PR guidelines. Please also follow the Code of Conduct.
Security vulnerabilities should be reported privately — see SECURITY.md.
See CHANGELOG.md for release history.
Copyright © 2024–2026 THISUX Private Limited.
Released under the MIT License. You may use, modify, and distribute this project for personal and commercial purposes, provided the copyright and permission notice are retained.
Created by Sanju, founder of THISUX Private Limited — a design-led product studio. If you need help building your next product, let's talk.