Skip to content

Instantly share code, notes, and snippets.

@rob-balfre
Created December 2, 2025 00:27
Show Gist options
  • Select an option

  • Save rob-balfre/56fd171c5a6f5b5eeeb7df545515d022 to your computer and use it in GitHub Desktop.

Select an option

Save rob-balfre/56fd171c5a6f5b5eeeb7df545515d022 to your computer and use it in GitHub Desktop.
Svelte 5 Drag & Drop System - Minimal, reusable drag and drop with pointer/touch events, drop zones, and mobile support
<script lang="ts">
import DraggableItem from './DraggableItem.svelte';
import DropZone from './DropZone.svelte';
interface Item {
id: string;
position: { x: number; y: number };
[key: string]: any; // Allow additional properties
}
interface Props {
/** Array of items to render as draggables */
items: Item[];
/** Called when an item is dismissed (dropped on zone) */
onDismiss?: (id: string) => void;
/** Called when an item's position changes */
onPositionChange?: (id: string, x: number, y: number) => void;
/** Slot for rendering item content */
itemContent?: import('svelte').Snippet<[Item]>;
}
let { items, onDismiss, onPositionChange, itemContent }: Props = $props();
// Track if any item is being dragged
let isDraggingAny = $state(false);
let isOverDropZone = $state(false);
function handleDragStart() {
isDraggingAny = true;
}
function handleDragEnd() {
isDraggingAny = false;
isOverDropZone = false;
}
function handleOverDropZoneChange(isOver: boolean) {
isOverDropZone = isOver;
}
function handleDrop(id: string) {
onDismiss?.(id);
isDraggingAny = false;
isOverDropZone = false;
}
</script>
<!-- Fixed overlay container -->
<div class="pointer-events-none fixed inset-0 z-50 overflow-hidden">
{#each items as item (item.id)}
<DraggableItem
id={item.id}
position={item.position}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDismiss={() => handleDrop(item.id)}
onOverDropZoneChange={handleOverDropZoneChange}
onPositionChange={(x, y) => onPositionChange?.(item.id, x, y)}
>
{#if itemContent}
{@render itemContent(item)}
{:else}
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-blue-500 text-white shadow-lg">
{item.id.slice(0, 2)}
</div>
{/if}
</DraggableItem>
{/each}
<DropZone {isVisible}={isDraggingAny} {isHovered}={isOverDropZone} />
</div>
<script lang="ts">
import { scale } from 'svelte/transition';
import { backOut } from 'svelte/easing';
interface Props {
/** Unique identifier for this item */
id: string;
/** Initial position */
position: { x: number; y: number };
/** Called when drag starts */
onDragStart?: () => void;
/** Called when drag ends */
onDragEnd?: () => void;
/** Called when item should be dismissed */
onDismiss?: () => void;
/** Called when position changes (for state sync) */
onPositionChange?: (x: number, y: number) => void;
/** Called when over drop zone state changes */
onOverDropZoneChange?: (isOver: boolean) => void;
/** Drop zone bounds { left, right, top, bottom } */
dropZoneBounds?: { left: number; right: number; top: number; bottom: number };
/** Slot content */
children?: import('svelte').Snippet;
}
let {
id,
position,
onDragStart,
onDragEnd,
onDismiss,
onPositionChange,
onOverDropZoneChange,
dropZoneBounds,
children
}: Props = $props();
// Local position state for smooth drag (no store round-trip)
let localPosition = $state({ x: position.x, y: position.y });
// Drag state
let isDragging = $state(false);
let didMove = $state(false);
let dragOffset = $state({ x: 0, y: 0 });
let isOverDropZone = $state(false);
// Store reference for pointer capture release
let element: HTMLElement | null = null;
// Default drop zone: bottom center of screen
const getDropZoneBounds = () => dropZoneBounds ?? {
left: window.innerWidth / 2 - 56,
right: window.innerWidth / 2 + 56,
top: window.innerHeight - 80,
bottom: window.innerHeight - 16
};
function checkDropZone(clientX: number, clientY: number) {
const bounds = getDropZoneBounds();
const wasOver = isOverDropZone;
isOverDropZone =
clientX >= bounds.left &&
clientX <= bounds.right &&
clientY >= bounds.top &&
clientY <= bounds.bottom;
if (isOverDropZone !== wasOver) {
onOverDropZoneChange?.(isOverDropZone);
}
}
// ==================== Pointer Events ====================
function handlePointerDown(event: PointerEvent) {
if (event.button !== 0) return; // Left click only
event.preventDefault();
event.stopPropagation();
element = event.currentTarget as HTMLElement;
element.setPointerCapture(event.pointerId);
isDragging = true;
didMove = false;
dragOffset = {
x: event.clientX - localPosition.x,
y: event.clientY - localPosition.y
};
onDragStart?.();
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
}
function handlePointerMove(event: PointerEvent) {
if (!isDragging) return;
didMove = true;
localPosition = {
x: event.clientX - dragOffset.x,
y: event.clientY - dragOffset.y
};
checkDropZone(event.clientX, event.clientY);
}
function handlePointerUp(event: PointerEvent) {
if (!isDragging) return;
if (element) {
element.releasePointerCapture(event.pointerId);
element = null;
}
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
if (isOverDropZone) {
onDismiss?.();
} else {
onPositionChange?.(localPosition.x, localPosition.y);
}
isDragging = false;
isOverDropZone = false;
onDragEnd?.();
}
// ==================== Touch Events ====================
function handleTouchStart(event: TouchEvent) {
if (event.touches.length !== 1) return;
event.preventDefault();
isDragging = true;
didMove = false;
const touch = event.touches[0];
dragOffset = {
x: touch.clientX - localPosition.x,
y: touch.clientY - localPosition.y
};
onDragStart?.();
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd);
document.addEventListener('touchcancel', handleTouchEnd);
}
function handleTouchMove(event: TouchEvent) {
if (!isDragging || event.touches.length !== 1) return;
event.preventDefault(); // Prevents scrolling during drag
didMove = true;
const touch = event.touches[0];
localPosition = {
x: touch.clientX - dragOffset.x,
y: touch.clientY - dragOffset.y
};
checkDropZone(touch.clientX, touch.clientY);
}
function handleTouchEnd() {
if (!isDragging) return;
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
document.removeEventListener('touchcancel', handleTouchEnd);
if (isOverDropZone) {
onDismiss?.();
} else {
onPositionChange?.(localPosition.x, localPosition.y);
}
isDragging = false;
isOverDropZone = false;
onDragEnd?.();
}
function handleClick(event: MouseEvent) {
// Only trigger click if we didn't drag
if (didMove) {
didMove = false;
event.stopPropagation();
}
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="pointer-events-auto absolute"
style="left: {localPosition.x}px; top: {localPosition.y}px; transform: translate(-50%, -50%); z-index: {isDragging ? 100 : 0};"
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="cursor-grab active:cursor-grabbing transition-transform duration-150
{isDragging ? 'scale-110' : ''}
{isOverDropZone ? 'opacity-50' : ''}"
style="touch-action: none;"
onpointerdown={handlePointerDown}
ontouchstart={handleTouchStart}
onclick={handleClick}
role="button"
tabindex="0"
in:scale={{ duration: 300, easing: backOut }}
out:scale={{ duration: 150 }}
>
{@render children?.()}
</div>
</div>
<script lang="ts">
interface Props {
/** Whether the drop zone should be visible */
isVisible: boolean;
/** Whether an item is hovering over the drop zone */
isHovered?: boolean;
/** Optional label text */
label?: string;
/** Slot content for custom icon */
children?: import('svelte').Snippet;
}
let { isVisible, isHovered = false, label = 'Drop here', children }: Props = $props();
</script>
<div
class="pointer-events-none fixed bottom-4 left-1/2 flex -translate-x-1/2 items-center justify-center transition-all duration-200
{isVisible ? 'opacity-100 scale-100' : 'opacity-0 scale-0'}
{isVisible && isHovered ? 'scale-125' : ''}"
>
<div
class="flex h-16 w-28 items-center justify-center gap-2 rounded-xl border-2 border-dashed backdrop-blur-sm transition-all duration-150
{isHovered
? 'border-red-400 bg-red-500/30 text-red-300'
: 'border-red-400/50 bg-red-500/10 text-red-400'}"
>
{#if children}
{@render children()}
{:else}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>
{/if}
{#if label}
<span class="text-sm font-medium">{label}</span>
{/if}
</div>
</div>
<script lang="ts">
import DragDropContainer from './DragDropContainer.svelte';
// State for items
let items = $state([
{ id: 'item-1', position: { x: 100, y: 100 }, label: 'Item 1', color: 'bg-blue-500' },
{ id: 'item-2', position: { x: 200, y: 150 }, label: 'Item 2', color: 'bg-green-500' },
{ id: 'item-3', position: { x: 150, y: 250 }, label: 'Item 3', color: 'bg-purple-500' },
]);
function handleDismiss(id: string) {
items = items.filter(item => item.id !== id);
}
function handlePositionChange(id: string, x: number, y: number) {
items = items.map(item =>
item.id === id ? { ...item, position: { x, y } } : item
);
}
</script>
<DragDropContainer
{items}
onDismiss={handleDismiss}
onPositionChange={handlePositionChange}
>
{#snippet itemContent(item)}
<div class="flex h-14 w-14 items-center justify-center rounded-full {item.color} text-white shadow-lg font-medium">
{item.label}
</div>
{/snippet}
</DragDropContainer>
<p class="fixed bottom-24 left-1/2 -translate-x-1/2 text-sm text-gray-500">
Drag items around. Drop on the zone to dismiss.
</p>

Svelte 5 Drag & Drop System

A minimal, reusable drag and drop system for Svelte 5 using pointer/touch events. Features smooth dragging, drop zones, and mobile support.

Features

  • Pointer capture for reliable drag tracking
  • Touch support with scroll prevention
  • Drop zone detection with hover feedback
  • Svelte 5 runes ($state, $derived)
  • No dependencies (pure Svelte)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment