Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

179 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@thisux/sveltednd

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.

npm version License: MIT Copyright skills.sh

Docs & community: Contributing · Security · Changelog · Code of Conduct

Features

  • Svelte 5 Native — Built on Svelte's $state runes 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

Installation

npm install @thisux/sveltednd
# or
bun add @thisux/sveltednd
# or
yarn add @thisux/sveltednd
# or
pnpm add @thisux/sveltednd

Agent skill

Coding 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 sveltednd

Skill source: skills/sveltednd/. Directory: skills.sh/thisuxhq/sveltednd.

Quick Start

<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}

Core Concepts

1. Draggable Items (use:draggable)

Make any element draggable with the draggable action:

<div use:draggable={{ container: 'my-list', dragData: item }}>
	{item.name}
</div>

2. Droppable Containers (use:droppable)

Create drop zones with the droppable action:

<div use:droppable={{ container: 'my-list', callbacks: { onDrop: handleDrop } }}>
	<!-- Draggable items go here -->
</div>

3. Global State (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}

API Reference

Draggable Options (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)

Droppable Options (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)

DragDropState Interface

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?
}

Callbacks

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;
}

Examples

Sortable List

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

Multiple Containers (Kanban Board)

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

Drag Handles

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>

Custom CSS Classes

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>

Grid Layout

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>

Horizontal Lists

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

Conditional Dropping

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>

Async Drop Handlers

Drop callbacks support async operations:

<div
	use:droppable={{
		container: 'list',
		callbacks: {
			onDrop: async (state) => {
				await saveToDatabase(state.draggedItem);
				await refreshData();
			}
		}
	}}
>
	<!-- Items -->
</div>

Default CSS Classes

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;
}

More Examples

Explore the demo pages for complete working examples:

Keyboard accessibility (opt-in)

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 onDrop contract 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').

Live demo: Keyboard · source

Using with Components — {@attach} (Svelte 5.29+)

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+ (fromAction is available in current 5.x). On older versions, wrap the component in a <div> and use use:draggable / use:droppable.

Live demo: Attachments · source

Browser Support

  • Chrome/Edge 88+
  • Firefox 78+
  • Safari 14+
  • Mobile Safari (iOS 14+)
  • Chrome for Android

Requirements

  • Svelte 5.0 or higher
  • TypeScript 5.0 or higher (optional but recommended)

Contributing

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.

Changelog

See CHANGELOG.md for release history.

License

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.

Acknowledgment

Created by Sanju, founder of THISUX Private Limited — a design-led product studio. If you need help building your next product, let's talk.

About

A lightweight, flexible drag and drop library for Svelte 5 applications.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

599 stars

Watchers

4 watching

Forks

Releases

Packages

Used by

Contributors

Languages