Neodrag v3: Modular, small, performant draggable library

4 months ago 4

Documentation is available on the Neodrag website.

After months of rebuilding from scratch, I’m releasing the Neodrag v3 Alpha. This isn’t just an update - it’s a complete rethink that makes drag-and-drop faster, more reliable, and infinitely extensible.

For those new to Neodrag, it’s a drag-and-drop library that works across all major JavaScript frameworks - Svelte, React, Vue, SolidJS, and vanilla JavaScript. While v2 served the community well, I kept hitting the same walls: performance bottlenecks in complex applications, rigid architecture that made customization difficult, and memory issues when scaling to hundreds of draggable elements.

v3 addresses these fundamental limitations through a complete architectural overhaul. Instead of a monolithic approach, v3 embraces composability through plugins. Instead of creating event listeners for every draggable element, it uses delegation. Instead of bundling everything whether you need it or not, it’s tree-shakable by design.

What’s New and Exciting

The transformation from v2 to v3 brings improvements across every dimension that matters for drag-and-drop interactions:

🚀 Blazing Performance
Event delegation means 3 total event listeners instead of 3 per draggable. Create 100 draggable elements? Still just 3 listeners. Your memory usage stays flat as you scale.

🧩 Plugin Everything
Want magnetic snapping? Write a 10-line plugin. Need momentum scrolling? Another plugin. Custom physics? Easy. The plugin system makes any drag behavior possible.

🛡️ Bulletproof Reliability
Plugin errors don’t crash your drag or break your page. Error isolation means one bad plugin won’t affect anything else.

📦 Tree-Shakable by Design
Only ship the plugins you actually use. Basic dragging is tiny, complex behaviors add exactly what they cost.

⚡ No More Event Listener Limits
v2 would hit browser event listener limits in complex apps. v3 scales infinitely through delegation.

See v3 in Action

The best way to understand v3’s capabilities is through examples. These five scenarios showcase how the plugin architecture transforms drag-and-drop from a rigid feature into a flexible foundation for interaction design:

1. Simple dragging

// Just make it draggable draggable(element, []);

2. Composed behavior

// Horizontal movement with grid snapping draggable(element, [axis('x'), grid([20, 20]), bounds(BoundsFrom.parent())]);

3. Reactive updates (Svelte)

<script> import { draggable, axis, grid, Compartment } from '@neodrag/svelte'; let isConstrained = $state(true); let gridSize = $state(20); const axisComp = Compartment.of(() => isConstrained ? axis('x') : null ); const gridComp = Compartment.of(() => gridSize > 0 ? grid([gridSize, gridSize]) : null ); </script> <div {@attach draggable(() => [axisComp, gridComp])}> Reactive dragging </div> <button onclick={() => isConstrained = !isConstrained}> Toggle constraint </button>

4. Custom plugin (magnetic snapping)

const magnetic = unstable_definePlugin((points) => ({ name: 'magnetic', drag(ctx) { const snap = points.find( (p) => Math.abs(ctx.offset.x - p.x) < 30 && Math.abs(ctx.offset.y - p.y) < 30, ); if (snap) { ctx.offset.x = snap.x; ctx.offset.y = snap.y; } }, })); draggable(element, [ magnetic([ { x: 100, y: 100 }, { x: 300, y: 200 }, ]), ]);

5. Error-proof dragging

const buggyPlugin = unstable_definePlugin(() => ({ name: 'buggy', drag() { throw new Error('Plugin crashed!'); }, })); // Drag still works even when plugin fails draggable(element, [ buggyPlugin(), // Fails silently axis('x'), // Keeps working grid([10, 10]), // Keeps working ]);

The Power of Plugins

This is where v3 fundamentally differs from every other drag-and-drop library. Instead of choosing between limited built-in options or writing everything from scratch, you compose behaviors like building blocks. Each plugin does one thing well, and you combine them to create exactly the interaction you need.

Instead of one monolithic library, you compose exactly what you need:

// Simple horizontal movement draggable(element, [axis('x')]); // Grid snapping with boundaries draggable(element, [grid([20, 20]), bounds(BoundsFrom.parent())]); // Complex behavior composition draggable(element, [ axis('both'), grid([10, 10]), bounds(BoundsFrom.viewport()), threshold({ distance: 5 }), events({ start: () => addClass('dragging'), end: () => removeClass('dragging'), }), ]);

Write Your Own Plugins

The real magic is how easy custom plugins are:

// Magnetic snapping in 15 lines const magnetic = unstable_definePlugin((snapPoints, threshold = 50) => ({ name: 'magnetic', drag(ctx) { const nearest = snapPoints.find( (point) => Math.abs(ctx.offset.x - point.x) < threshold && Math.abs(ctx.offset.y - point.y) < threshold, ); if (nearest) { ctx.offset.x = nearest.x; ctx.offset.y = nearest.y; } }, })); // Use it immediately draggable(element, [ magnetic([ { x: 100, y: 100 }, { x: 200, y: 200 }, ]), axis('both'), ]); // Live position tracking const tracker = unstable_definePlugin((callback) => ({ name: 'tracker', drag: (ctx) => callback(ctx.offset.x, ctx.offset.y), })); // Visual feedback plugin const shadow = unstable_definePlugin(() => ({ name: 'shadow', drag(ctx) { ctx.scheduleEffect(() => { element.style.boxShadow = `${ctx.offset.x}px ${ctx.offset.y}px 10px rgba(0,0,0,0.3)`; }); }, }));

Performance That Scales

In drag-and-drop libraries, performance isn’t just about being fast - it’s about staying fast as your application grows. v2’s approach of creating event listeners for each draggable element worked fine for simple cases but became a memory nightmare in complex UIs with dozens or hundreds of interactive elements.

The event delegation improvement is dramatic:

// v2: Creates 300 event listeners for (let i = 0; i < 100; i++) { draggable(elements[i], { axis: 'x' }); } // v3: Uses 3 event listeners total for (let i = 0; i < 100; i++) { draggable(elements[i], [axis('x')]); }

No more memory leaks from event listeners. No more browser limits on complex UIs.

Dynamic Behavior Made Simple

Modern applications need drag-and-drop behaviors that can change in response to user actions, application state, or external data. v2 made this possible but clunky - you had to destroy and recreate draggable instances. v3’s compartment system makes reactive updates seamless and performant.

Reactive updates through compartments:

// Switch behaviors on the fly let currentMode = 'constrained'; const modeComp = new Compartment(() => currentMode === 'constrained' ? bounds(BoundsFrom.parent()) : axis('both'), ); draggable(element, () => [modeComp]); // Update instantly currentMode = 'free'; modeComp.current = axis('both');

Framework Support Everywhere

All the frameworks you love:

<!-- Svelte --> <div {@attach draggable([axis('x'), grid([20, 20])])}> Horizontal grid snapping </div> // React function Card() { const ref = useRef(null); useDraggable(ref, [bounds(BoundsFrom.viewport())]); return <div ref={ref}>Constrained card</div>; } <!-- Vue --> <template> <div v-draggable="[axis('y'), grid([15, 15])]">Vertical movement</div> </template> // SolidJS function Card() { const [ref, setRef] = createSignal(); useDraggable(ref, [bounds(BoundsFrom.viewport())]); return <div ref={setRef}>Constrained card</div>; } <!-- Vanilla/CDN --> <script src="https://unpkg.com/@neodrag/vanilla@next/dist/umd/index.js"></script> <script> new NeoDrag.Draggable(element, [ NeoDrag.axis('x'), NeoDrag.bounds(NeoDrag.BoundsFrom.parent()), ]); </script>

The New API

v3 moves from an options object to a plugin-based approach:

// v2: Single options object draggable(element, { axis: 'x', bounds: 'parent', grid: [10, 10], handle: '.drag-handle', onDrag: (data) => updatePosition(data), }); // v3: Composable plugins draggable(element, [ axis('x'), bounds(BoundsFrom.parent()), grid([10, 10]), controls({ handle: ControlFrom.selector('.drag-handle') }), events({ drag: (data) => updatePosition(data) }), ]);

Key Differences from v2

Understanding these changes will help you appreciate why v3 represents such a significant step forward, even if it requires some migration effort:

Reactivity: v2 had automatic reactivity with performance overhead. v3 uses manual reactivity for precise control:

// v2: Automatic (but costly) const [currentAxis, setCurrentAxis] = useState('x'); draggable(element, { axis: currentAxis, }); // v3: Manual (performant) const [currentAxis, setCurrentAxis] = useState('x'); const axisComp = useCompartment(() => axis(currentAxis), [currentAxis]); draggable(element, () => [axisComp]); setCurrentAxis('y');

Bundle impact:

  • v2: 2.2KB for everything, whether you use it or not
  • v3: 3.5KB core + only the plugins you import (tree-shakable)

The Architecture Behind It

This performance and flexibility comes from a mini-framework architecture:

Core as peer dependency: @neodrag/core is a peer dependency across all framework packages. This enables better deduplication and consistent behavior in meta-frameworks like Astro.

Event delegation: 3 global listeners handle all draggables through delegation, not 3 per element.

State isolation: Each draggable maintains its own state, plugins store their own data:

const counterPlugin = unstable_definePlugin(() => ({ name: 'counter', setup: () => ({ moveCount: 0 }), drag: (ctx, state) => { state.moveCount++; console.log(`Move #${state.moveCount}`); }, }));

Error isolation: Plugin failures don’t break the drag or crash your page:

const buggyPlugin = unstable_definePlugin(() => ({ name: 'buggy', drag() { throw new Error('Oops!'); }, })); // Other plugins continue working draggable(element, [ buggyPlugin(), // Fails safely axis('x'), // Still works grid([20, 20]), // Still works ]);

Effect system: Schedule work after DOM updates:

const shadowPlugin = unstable_definePlugin(() => ({ name: 'shadow', drag(ctx) { ctx.effect.paint(() => { element.style.boxShadow = `${ctx.offset.x}px ${ctx.offset.y}px 10px rgba(0,0,0,0.3)`; }); }, }));

Migration from v2

Most migrations are straightforward - convert options to plugins:

// v2 draggable(element, { axis: 'x', grid: [20, 20], bounds: 'parent', }); // v3 draggable(element, [axis('x'), grid([20, 20]), bounds(BoundsFrom.parent())]);

The main work is handling reactivity changes since it’s now manual. But you get better performance and predictable behavior in return.

Try It Today

# Pick your framework npm install @neodrag/core@next npm install @neodrag/core@next @neodrag/svelte@next npm install @neodrag/core@next @neodrag/react@next npm install @neodrag/core@next @neodrag/vue@next npm install @neodrag/core@next @neodrag/solid@next npm install @neodrag/core@next @neodrag/vanilla@next

Documentation is available on the Neodrag website.

This is an alpha release - the core is solid but I’m looking for feedback on the plugin API, framework integration, and real-world performance.

File issues on GitHub, join discussions, or reach out on Twitter @puruvjdev with your experiments.

The plugin architecture opens up endless possibilities. I can’t wait to see what you build.


This has been a long project, but the extensibility and performance improvements make it worthwhile. Let me know what you think.

Read Entire Article