It has been 2 years since Neodrag v2 came out. It started as a simple dragging library for my own needs for the macos web React to Svelte rewrite, where I was using the nice react-draggable library before. Unfortunately(Or fortunately, depending on your point of view) Svelte at that time didn’t have a good dragging library for my needs. So I decided to make one.
Constraints: Same API as react-draggable. But has to be zero-dependency and small. And small it was! svelte-drag wa sjust 2KB min+gzip, satisfied all my needs, and was usable in this beautiful manner:
<script>
import { draggable } from 'svelte-drag';
</script>
<div use:draggable />
Then I realized, hey, this Svelte Action I wrote, is just a simple function that takes in a node and an options object, and returns { update, destroy } pair. There is nothing Svelte-specific here! So I could just use that to my advantage and bring it to React as well, since I was working on React projects professionally. And I did.
Only I intended to release it as react-drag, but somehow that was taken. So I went down this bikeshedding of names, and was discussing this with my mother, when she suggested “neo”. Something with the word neo in it. I liked the idea of the name. It had a nice ring to it. But then the mroe I thought, the better it seemed: neo, is an anagram of the word “one”. Inspired from the Matrix’s Neo. And “One” could mean one library for all the frameworks.
Which brings us to the tagline: One draggable to rule them all, shamelessly stolen from one of my favorite movies: The Lord of the Rings
Hence neodrag was born. And not just @neodrag/svelte and @neodrag/react, but @neodrag/vue, @neodrag/solid and @neodrag/vanilla as well.
Neodrag v2 has done quite well till now. It has grown at a slow and steady pace, and I am quite happy with it. But I have been thinking about it for a while, and I realized that I could do a lot better.
Motivation
The biggest issues on github are all about the most basic things:
- How do I modify bounds?
- How do I make it snap to other elements?
- Can the magnetic mode be realized?
- How can I put obstacles so that the user can’t drag within them?
- It blocks main-thread, how do I defer the transform?
In simple terms, everyone wants the library to do something different. Many of these things are hard to do, some would require breaking changes, and some are outright too big to implement in the tiny 2KB library that I hope to keep it as. In simple terms, if I implement all of these, the library would bloat past 10KB, and have a very weird conditional-like API for all the use-cases. Just not good at all.
Meanwhile, on a personal level, I do this work in free-time, and many times I am working on something new and novel in this free-time, hence I am not always able to provide support on github. This means the project goes into a little limbo every once in a while, which means users are kind of stuck on me pushing code. Not ideal.
Hence, I wish to reduce the project’s dependency on me a lot with this release. And I came up with quite a nice design!
Everything is a Plugin
Neodrag v3 is just a composition of plugins. Plugins are functions which provide hooks for different lifecycles of the dragging cycle, and can suggest different behaviors to the library.
Here’s how its usage looks like:
<script>
import { draggable, grid, scrollLock } from '@neodrag/svelte';
</script>
<div use:draggable={[
grid([10, 50]),
scrollLock()
]}
></div>
Yepp, you are not tripping: There is no object as configuration anymore: No { grid: [200, 50] } etc, which have to be baked into the core library. Instead, we are passing draggable an array of plugins, which are functions that return a plugin object.
Out of the box, the library will come with a qide variety of plugins:
- ignoreMultitouch
- stateMarker (replaces the classesd with data-neodrag-*)
- axis
- applyUserSelectHack
- grid
- disabled
- transform
- bounds
- threshold
- events
- controls (previously
handleandcancel) - position
- touchAction
- scrollLock
If you were a neodrag v2 user, you would notice many of the previous options here. Almost all the options are here and they’re the same, only, they are plugins instead of object options now. This library is now a composition of plugins, and not a single monolithic function. Neodrag v3’s core is just a fancy state machine keeping track of states related to dragging.
Think of how React just takes care of the state bits and react-dom reconciles that state with the DOM. Or how react-native is able to do the same thing.
Structure of a plugin
Let me show you the axis plugin: axis plugin restricts the dragging to just the one axis the user specifies. Here is the code:
export const axis = definePlugin<[value?: 'x' | 'y']>({
name: 'neodrag:axis',
drag([value], ctx) {
// Let dragging go on if axis is undefined
if (!value) return;
ctx.propose(
value === 'x' ? ctx.proposed.x : null,
value === 'y' ? ctx.proposed.y : null,
);
},
});
Let’s break it down. First we define the plugin using definePlugin helper function. This is more than an identity function, as it caches the plugin object. The generic type argument [value?: 'x' | 'y'] is an explicit way of defining the arguments the plugin takes. This is necessary for caching the plugin.
Next up we got the name: neodrag:axis.
Next, we make sure value, the argument is present and not undefined. If it is, we don’t want to interfere with the dragging, so we return early.
next up is ctx.propose(). This is the core tenet of the plugin system. It is a function that takes in two arguments, and proposes a new value for the dragging. In this case, we are proposing the value of either x or y axis, depending on the argument we got. for X field, If the value is x, we tell it to take the proposed value from before and use it. Vice-verca for y field.
ctx.proposed is the value that the “previous” plugin proposed. In v3, plugins don’t set the final offset themselves. They propose the very few pixels the draggable should move by in each pointermove event. Once all the plugins are done running, the core library will tske the proposed value, and apply it to the element.
But this was a very simple plugin. Lemme show you a more complex one:
This is the ignoreMultitouch plugin. It is a plugin that ignores multitouch gestures. It does this by checking if the event has more than one pointer, and if it does, it prevents the default action of the event, which is to start a multitouch gesture.
export const ignoreMultitouch = unstable_definePlugin<[value?: boolean]>(
{
name: 'neodrag:ignoreMultitouch',
setup() {
return {
active_pointers: new Set<number>(),
};
},
start(args, ctx, state, event) {
ctx.effect(() => {
state.active_pointers.add(event.pointerId);
if (args && state.active_pointers.size > 1) {
event.preventDefault();
}
});
},
drag(args, ctx, state) {
if (args && state.active_pointers.size > 1) {
ctx.cancel();
}
},
end(_args, _ctx, state, event) {
state.active_pointers.delete(event.pointerId);
},
},
[true],
);
setup is run when the draggable is initialized. The value it returns is the state persisted throughout the lifecycle of the plugin. In this case, we are keeping track of the active pointers. The state is available to each hook of the plugin, and its type is inferred automatically.
Next, if you see the start hook, this is where the dragging begins. We add the current pointerID to the set of active pointers. If the plugin is configured to ignore multitouch, and the event has more than one pointer, we prevent the default action of the event, which is to start a multitouch gesture.
ctx.effect() is the way of running a DOM-related function when the dragging is deemed successful. This is run with requestAnimationFrame, to batch the effects in all the plugins and run them at once, to avoid layout thrashing.
The drag hook is run when the user is dragging. If the plugin is configured to ignore multitouch, and the event has more than one pointer, we cancel the dragging with ctx.cancel().
Lastly, we end it with end hook. This is run when the dragging ends. We remove the pointer from the set of active pointers.
Hope this paints the image a bit better
Why tho?
Why turn everything into a plugin? There are some really good reasons:
Tree-shakable
In Neodrag v2, the users only using the basic library have to pay for grid, bounds, threshold, ignoreMultitouch etc when these were options and included within the core library. Granted, the whole library is just 2KB, so it doesn’t matter much. But still, why pay for the entire happy meal when you just wanted the fries!
With this new architecture, you import the plugins. Hence you pay for what you actually use, not for the entire library and all its mighty features.
BYODraggable
Bring Your Own Draggable: If you do not agree with any part of the library, or a built-in plugin, you don’t have to open github issues and convince me to change it. You can just write your own plugin!! Just use your own and throw out the built-in behaviors you don’t like.
Ecosystem
This is reaching a bit far, but this change could unlock a whole ecosystem of plugins. For example:
neodrag-plugin-snapneodrag-plugin-better-gridneodrag-plugin-magneticneodrag-plugin-autoscroll
All these are plugins that could exist in the userland on npm. You would just npm install them, import, and use them. That’s it!
What else is new?
Tons!
Tuned runtime performance
This time, instead of focusing solely on bundle size reducton, which often causes you to write slower code compared to the faster but bigger version, I focused on runtime perf. Constantly running the profiler, comparing Task, checking requestAnimationFrame triggers and so much more! On safari alone, the dragging has become significantly smoother than v2 with these changes.
Event delegation
Neodrag works with Pointer Events: You have dragstart on pointerdown, dragging on pointermove and dragEnd on pointerup. The 3 events are registered on the window object, everytime a draggable is registered. Which means, if you have 100 draggables, you have 300 event listeners just watching and running for no reason. This is a huge waste of memory.
With v3, I’m taking a page out of Svelte 5’s handbook, and using event delegatin system. Now only the 3 event listeners will be registered on the <body>, and only when the very first draggable is registered. Even if you have 100 draggables, only 3 event listeners will be there in total, saving tons of memory!
I know 100 seems a lot but it really isn’t, on the docs site neodrag.dev, there are 80 or so draggable on the page, and it can get sluggish on slower devices. This will completely fix that issue.
Pointer captured!
Something very simple I had overlooked in the v2: Forgetting to run setPointerCapture on the draggable element. This meant that if you were dragging the element on top of an iframe and your cursor went outside your draggable, you lost dragging. The pointer events would go to iframe.
With v3, setPointerCapture fully avoids this issue. You can now drag the element on top of an iframe as fast as you want, and your pointer will never ever lose it.
Errors & Graceful recovery
To reduce bundle size on neodrag v2, I opted to removed all the errors in favor of TypeScript being the error enforcer. This ofc meant it excluded non-TS users, since they were running blind with this library. Hence, this time, I have added conditional errors to the library: In dev mode, you will get tons of errors, and in prod, none! They will be tree-shaken out.
Moreover, the library tries to graceful recover from errors, and will only console.error them, not throw them and stop all JavaScript execution. You can still control the errors with the new onError hook, and throw them yourself if you want.
import { createDraggable } from '@neodrag/core';
const { draggable } = createDraggable({
onError(error) {
throw error;
},
});
Low level control
You can import draggable from the @neodrag/{svelte,react,vue,solid,vanilla} packages ofc, but if you want to have full control over the draggaing, you can import the createDraggable function from @neodrag/core. This function returns the draggable instance itself, which you can use.
import { createDraggable } from '@neodrag/core';
const {
draggable, // The draggable instance
instances, // A Map of all the draggable instances
dispose // Disposes of the event listeners, destroys all the instances
} = createDraggable({
plugins: [grid(), transform()] // Overrides the default plugins
delegate: () => document.body, // This is where the events are registered () => document.body
onError(error) {
throw error; // Overrides the default `console.error`
},
});
// Later
import { reactWrapper } from '@neodrag/react';
export const useDraggable = reactWrapper(draggable); // Or write your own!
Testing
Neodrag v2 has had Vitest+JSDom tests in it for a while, but I’ll admit: I did not write them! A contributor, tascodes generously contributed the thousand-line test suite. However, I never truly used these tests myself, and that showed up in weirdest bugs and regressions.
Hence this time, I am going all in on testing. And not JSDOM/HappyDOM simulated-browser-in-node testing, but real browser testing: I’m going all in on Playwright, with over 5 browsers and form-factors. This is a huge step, and I am very happy with the results. Even while writing this article, I am constantly finding a bug/unintended behavior in things and constantly fixing it, all because I’m writing a very basic test! 🔥
Expect v3 to be robust and stable!
Some (breaking) things
Some things are changing which could be seen as negatives, but in long term I think will be absolutely worth it!
Increased bundle size
Yes, this one is painful to write but absolutely necessary.
This code:
import { draggable } from '@neodrag/svelte';
draggable();
is 3.64KB min+brotli. That is twice the size from before. This includes 6 default plugins as well. Without them it would be smaller ofc. But I don’t want to include that size since you can’t do much without plugins.
I feel the user landscape has changed since 2021 when I made this library. Internet connections are faster, average devices are getting more powerful. And let’s be honest, 3.6KB is not that much. If you need a draggable library in your app, it is probably interactve enough that 3.6KB is the least of your worries.
Any more plugins will add to the size, but if you don’t need them, you don’t pay for them!
One (internal) dependency
Until now, each @neodrag/{svelte,react,vue,solid,vanilla} package had 0 dependencies. THis was done by bundling the core library with each adapter. This was all good, meant you only downloaded one dependency instead of two(wrapper and the core), but it has issues:
- Astro allows multiple frameworks, and if each framework is using a draggable, then you will end up with 2 copies of core code, it won’t be deduped.
- V3 allows you to work with the core for more control.
Hence, Each neodrag library will have a dependency on @neodrag/core. Rest assured, there wil never be an external dependency in any of the packages. Ever!
Removed legacyTranslate and gpuAcceleration
legacyTranslate was there to use the transform: translate(x, y) property to move the element. This was a very simple solution, but it has a big issue: You can’t easily rotate or skew or scale your element, since the library itself is hijacking all the ways of doing that. This caused users to use tons of hacks to get around it, and it wasn’t pretty.
So I introduced transform function which gives you the offsets, the rootNode and you can apply the transform yourself. This is just a hacky solution though I feel.
So I introduced legacyTranslate option as false, and set the default to use the translate. This works the sasme as above but does not hijack all the different transforms! And it also does not require the 1px hack to trigger GPU acceleration, it is GPU accelerated by default.
Hence both of these are going away. However, the transform function is not, and can be used like this:
<script>
import { draggable, transform } from '@neodrag/svelte';
function handleDrag({ offset, rootNode }) {
rootNode.style.transform = `translate(${offset.x}px, ${offset.y}px)`;
}
</script>
<div
use:draggable={[transform(handleDrag)]}
></div>
handle and cancel are no more
Or to be accurate, have been consolidate into a single controls plugin. This plugin is a function that takes in a allow and a block elements.
import { draggable, controls } from '@neodrag/svelte';
const { draggable } = createDraggable({
plugins: [
controls({
allow: '.allow',
block: '.block,
}),
],
});
No more classes
Before, the library would add classes like neodrag, neodrag-dragging, neodrag-dragged to the element. Modifying classes may cause layout recalculation and extra unnecessary work, and may conflict with userland classes-handling logic, hence neodrag v3 will instead set data-neodrag-state="idle" and other data-neodrag-* attributes.
What’s next?
I will be writing more and more tests, writing framework adapters, overhauling the website(Will be making an entire REPL just for neodrag). Another important focus is to reduce the bytes as much as possible while keeping the runtime perf fast
The First Beta will launch by the end of January, do try it out and give feedback! 😄
Peace!