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