I've been scratching my head trying to find a way to drag and drop sort in Svelte. I build some things form scratch, but they were really not maintainable. I do not want to maintain a repository at this point, although that could change.
SortableJS
SortableJS works in every other framework, why not Svelte 5? Instead of building a custom component, I figured I would build a custom hook.
Enter useSortable
First, install the necessary packages.
npm i -D sortablejs @types/sortablejs
🟥 It is always a red flag when we have to install separate types. With the exception of JSDoc and Svelte core coding, I don't trust the work on non-Typescript developers. That being said, maybe this package is so old that it has been modified since before TS? Either way, it is maintained, so I trust it better than building something.
Create Our Hook
import Sortable from 'sortablejs';
export const useSortable = (
getter: () => HTMLElement | null,
options?: Sortable.Options
) => {
$effect(() => {
const sortableEl = getter();
const sortable = sortableEl ?
Sortable.create(sortableEl, options)
: null;
return () => sortable?.destroy();
});
}
That's it! Well, sort of...
If we want to save our sort order, we will need a function to return our new items.
export function reorder<T>(
array: T[],
evt: Sortable.SortableEvent
): $state.Snapshot<T>[] {
// should have no effect on stores or regular array
const workArray = $state.snapshot(array);
// get changes
const { oldIndex, newIndex } = evt;
if (oldIndex === undefined || newIndex === undefined) {
return workArray;
}
if (newIndex === oldIndex) {
return workArray;
}
// move elements
const target = workArray[oldIndex];
const increment = newIndex < oldIndex ? -1 : 1;
for (let k = oldIndex; k !== newIndex; k += increment) {
workArray[k] = workArray[k + increment];
}
workArray[newIndex] = target;
return workArray;
}
Usage
We must pass in our parent HTML Element. The children get sorted.
<script lang="ts">
import { reorder, useSortable } from '$lib/use-sortable.svelte';
...
let sortable = $state<HTMLElement | null>(null);
useSortable(() => sortable);
...
</script>
...
<ul bind:this={sortable}>
<li>Child 1</li>
<li>Child 2</li>
...
We can also pass all the options for SortableJS as the second parameter. See SortableJS Docs.
useSortable(() => sortable, {
animation: 200,
handle: '.my-handle',
ghostClass: 'opacity-0'
});
Saving the New State
Now, we need to save the state of our new sortable array.
let items = $state([
{
id: 1,
text: 'Item 1'
},
{
id: 2,
text: 'Item 2'
},
{
id: 3,
text: 'Item 3'
}
]);
let sortable = $state<HTMLElement | null>(null);
useSortable(() => sortable, {
animation: 200,
handle: '.my-handle',
ghostClass: 'opacity-0',
onEnd(evt) {
items = reorder(items, evt);
}
});
The onEnd
event handler returns an event object containing the new and old positions for our array after the items have been dropped. We can reorder our original array by importing our reorder
function, and passing this event to it.
Each Loop
Make sure to have our array use the entire value as the key, this is the item in ()
.
{#each items as item (item)}
...
{/each}
Final Code
<script lang="ts">
import Handle from '$lib/handle.svelte';
import { reorder, useSortable } from '$lib/use-sortable.svelte';
let items = $state([
{
id: 1,
text: 'Item 1'
},
{
id: 2,
text: 'Item 2'
},
{
id: 3,
text: 'Item 3'
}
]);
let sortable = $state<HTMLElement | null>(null);
useSortable(() => sortable, {
animation: 200,
handle: '.my-handle',
ghostClass: 'opacity-0',
onEnd(evt) {
items = reorder(items, evt);
}
});
</script>
<div class="hidden opacity-0"></div>
<ul class="flex w-full list-none flex-col items-center" bind:this={sortable}>
{#each items as item (item)}
<li class="m-2 flex w-32 items-center justify-center gap-5 border p-3">
<span>{item.text}</span>
<button type="button" class="my-handle outline-none">
<Handle />
</button>
</li>
{/each}
</ul>
<div class="flex justify-center">
<pre class="mt-5 w-fit border p-5">{JSON.stringify(items, null, 2)}</pre>
</div>
Ghost Class
I don't want to add a style
tag, so for my ghost class I am using:
<div class="hidden opacity-0"></div>
This ensures opacity-0
gets compiled from Tailwind.
Final Hook
// use-sortable.svelte.ts
import Sortable from 'sortablejs';
export const useSortable = (
getter: () => HTMLElement | null,
options?: Sortable.Options
) => {
$effect(() => {
const sortableEl = getter();
const sortable = sortableEl ?
Sortable.create(sortableEl, options)
: null;
return () => sortable?.destroy();
});
}
export function reorder<T>(
array: T[],
evt: Sortable.SortableEvent
): $state.Snapshot<T>[] {
// should have no effect on stores or regular array
const workArray = $state.snapshot(array);
// get changes
const { oldIndex, newIndex } = evt;
if (oldIndex === undefined || newIndex === undefined) {
return workArray;
}
if (newIndex === oldIndex) {
return workArray;
}
// move elements
const target = workArray[oldIndex];
const increment = newIndex < oldIndex ? -1 : 1;
for (let k = oldIndex; k !== newIndex; k += increment) {
workArray[k] = workArray[k + increment];
}
workArray[newIndex] = target;
return workArray;
}
We use $state.snapshot
to get rid of the proxy and create a clone array. Then we set the data back in our onEnd
statement.
Stores
This works with stores as well. I hate supporting something that will get depreciated, but unfortunately, we have too many packages that may never update to runes
, like Superforms. Keep in mind certain components in shadcn-svelte depend on Superforms, so we can be stuck with slower performance for many packages. I, personally, hope stores get depreciated sooner than later so we can have a better Svelte.
Hope this helps someone,
J
Top comments (4)
Good work, but why the hooks pattern? That's too "reacty" for my taste. Svelte has this amazingly powerful (and soon to mutate to an even more powerful "attachment") feature called "actions". Make an action.
You could easily do that. I just prefer the hook pattern as I find it to be super clean. It is also what huntabyte does for Runed with the exception of classes.
That would be huntabyte, and I still don't like it. I find actions to be much cleaner. Attachments will replace actions and will be available for components, so it might be a good idea to drop the hooks syntax. Just friendly advice.
Typo fixed :) Hooks and the
use
syntax are popular in all frameworks, including Svelte and Vue, and are just a preference. I doubt they will go away completely for situations where you don't attach directly to an element. That being said, when attachement makes it to production and they finalize the syntax, this would be a good use case to attach directly to the element instead of passing it as a parameter, so I can look at updating it then.