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.
$state runes for reactive state managementnpm install @thisux/sveltednd
# or
bun add @thisux/sveltednd
# or
yarn add @thisux/sveltednd
# or
pnpm add @thisux/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}
use:draggable)Make any element draggable with the draggable action:
<div use:draggable={{ container: 'my-list', dragData: item }}>
{item.name}
</div>
use:droppable)Create drop zones with the droppable action:
<div use:droppable={{ container: 'my-list', callbacks: { onDrop: handleDrop } }}>
<!-- Draggable items go here -->
</div>
dndState)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}
DraggableOptions<T>)| 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) |
DragDropOptions<T>)| 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:
Svelte actions (use:draggable, use:droppable) only work on native HTML elements, not components. If you need to use drag and drop on a component, Svelte 5.29+ provides fromAction to convert actions into attachments that pass through component props:
<script lang="ts">
import { fromAction } from 'svelte/attachments';
import { draggable, droppable } from '@thisux/sveltednd';
</script>
<!-- Works on components that spread props onto their root element -->
<Card {@attach fromAction(draggable, { container: 'list', dragData: item })}>
{item.title}
</Card>
<Column {@attach fromAction(droppable, { container: 'todo', callbacks: { onDrop: handleDrop } })}>
<!-- draggable items -->
</Column>
The component just needs to spread its props onto a root element:
<!-- Card.svelte -->
<script>
let { children, ...props } = $props();
</script>
<div {...props}>
{@render children?.()}
</div>
Note: Requires Svelte 5.29 or newer. On older versions, wrap the component in a
<div>with the action instead.
We welcome contributions! Please see CONTRIBUTING.md for guidelines.
MIT License — see LICENSE for details.
Created by Sanju, founder of ThisUX Private Limited — a design-led product studio. If you need help building your next product, let's talk.