Svelte 5 and SortableJS

Jonathan Gamble - Feb 2 - - Dev Community

Sortable in Svelte 5

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
Enter fullscreen mode Exit fullscreen mode

🟥 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();
    });
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
  ...
Enter fullscreen mode Exit fullscreen mode

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'
});
Enter fullscreen mode Exit fullscreen mode

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);
    }
});
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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.

Demo: Vercel
Repo: GitHub

Hope this helps someone,

J

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .