View as Markdown

Sortable

A Stimulus behavior that reorders a list with drag-and-drop and, optionally, saves the new position of each moved item to your server. Powered by SortableJS. Put it on any container, like a list, a table body or a grid, and its children become draggable.

100%
Loading...
<ul data-controller="sortable" class="">
    <li class="">Pet the cat</li>
    <li class="">Get the groceries</li>
    <li class="">Water the plants</li>
    <li class="">Call the plumber</li>
</ul>

Installation

Note

Available since UX Toolkit 3.7.

php bin/console ux:install sortable --kit common

Install the following front-end dependencies:

npm install sortablejs
# ...or with Symfony AssetMapper
php bin/console importmap:require sortablejs

Copy the following file(s) into your app:

assets/controllers/sortable_controller.js
import { Controller } from '@hotwired/stimulus';
import Sortable from 'sortablejs';

/**
 * @value  animation     How long, in milliseconds, the items take to slide into their new place while sorting; 0 disables the animation.
 * @value  handle        CSS selector of the drag handle inside each item; when omitted, an item is dragged from anywhere.
 * @value  draggable     CSS selector of the items that can be dragged; when omitted, the `li` children of a list, or all the children of any other element.
 * @value  filter        CSS selector of the elements that do not start a drag when pressed, like the buttons inside the items.
 * @value  paramName     Name of the field that carries the new position of the moved item among the draggable ones, starting at 1; a value like `task[position]` nests it like a Symfony form field.
 * @value  csrfToken     CSRF token sent with the request as the `_token` parameter, e.g. from `csrf_token('reorder-tasks')`.
 */
export default class extends Controller {
    static values = {
        animation: { type: Number, default: 150 },
        handle: String,
        draggable: String,
        filter: String,
        paramName: { type: String, default: 'position' },
        csrfToken: String,
    };

    #sortable;
    #saves = Promise.resolve();

    connect() {
        this.#sortable = new Sortable(this.element, {
            animation: this.animationValue,
            handle: this.handleValue || undefined,
            filter: this.filterValue || undefined,
            // SortableJS only falls back to its default, depending on the element, when the option is missing.
            ...(this.draggableValue && { draggable: this.draggableValue }),
            // Fires only when an item lands at a new index, so dropping it back in place sends no request.
            onUpdate: (event) => {
                this.#saves = this.#saves.catch(() => {}).then(() => this.#save(event));
            },
        });
    }

    disconnect() {
        this.#sortable.destroy();
    }

    async #save({ item, newDraggableIndex }) {
        const url = item.dataset.sortableUpdateUrl;
        if (!url) {
            return;
        }

        const body = new FormData();
        body.append(this.paramNameValue, String(newDraggableIndex + 1));
        if (this.hasCsrfTokenValue) {
            body.append('_token', this.csrfTokenValue);
        }

        const response = await fetch(url, {
            method: 'POST',
            headers: {
                Accept: window.Turbo
                    ? 'text/vnd.turbo-stream.html, text/html, application/xhtml+xml'
                    : 'text/html, application/xhtml+xml',
                'X-Requested-With': 'XMLHttpRequest',
            },
            body,
        });

        if (response.headers.get('Content-Type')?.startsWith('text/vnd.turbo-stream.html')) {
            window.Turbo?.renderStreamMessage(await response.text());
        }
    }
}

Usage

Add data-controller="sortable" to the element whose children you want to reorder:

<ul data-controller="sortable">
    <li>Pet the cat</li>
    <li>Get the groceries</li>
</ul>

The other items slide out of the way as you drag. Set animation to change how long this takes, in milliseconds (150 by default). Set it to 0 to disable the animation.

Examples

Drag Handle

Set handle to a CSS selector to drag the items by a handle only. The rest of each item stays free, so you can select its text or click its links:

100%
Loading...
<ul data-controller="sortable" data-sortable-handle-value="[data-handle]" class="">
    <li class="">
        <span data-handle class=""><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" class=""><circle cx="9" cy="5" r="1.5"/><circle cx="15" cy="5" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="19" r="1.5"/><circle cx="15" cy="19" r="1.5"/></svg></span>
        Pet the cat
    </li>
    <li class="">
        <span data-handle class=""><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" class=""><circle cx="9" cy="5" r="1.5"/><circle cx="15" cy="5" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="19" r="1.5"/><circle cx="15" cy="19" r="1.5"/></svg></span>
        Get the groceries
    </li>
    <li class="">
        <span data-handle class=""><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" class=""><circle cx="9" cy="5" r="1.5"/><circle cx="15" cy="5" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="19" r="1.5"/><circle cx="15" cy="19" r="1.5"/></svg></span>
        Water the plants
    </li>
</ul>

Fixed Items

Set draggable to a CSS selector to choose which items can be dragged. By default, these are the li children of a list, or all the children of any other element. The other items stay where they are, like the slot to add a photo at the end of this grid. Start the selector with > to match the children of the controller element only, and not the elements inside them. When you save the new position, only the draggable items are counted.

100%
Loading...
<div data-controller="sortable" data-sortable-draggable-value=">:not([data-upload])" class="">
    <div class="">A</div>
    <div class="">B</div>
    <div class="">C</div>
    <div class="">D</div>
    <div class="">E</div>
    <div data-upload class="">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" class=""><path d="M12 5v14M5 12h14"/></svg>
        Add a photo
        <input type="file" accept="image/*" aria-label="Add a photo" class="">
    </div>
</div>

Ignored Elements

Set filter to a CSS selector of the elements that do not start a drag, like the buttons inside the items. Pressing one of them does not pick up its item.

100%
Loading...
<ul data-controller="sortable" data-sortable-filter-value="button" class="">
    <li class="">
        Pet the cat
        <button type="button" class="">Edit</button>
    </li>
    <li class="">
        Get the groceries
        <button type="button" class="">Edit</button>
    </li>
    <li class="">
        Water the plants
        <button type="button" class="">Edit</button>
    </li>
</ul>

Grid

The items do not have to be stacked. In a grid, they move along both axes.

100%
Loading...
<div data-controller="sortable" class="">
    <div class="">A</div>
    <div class="">B</div>
    <div class="">C</div>
    <div class="">D</div>
    <div class="">E</div>
    <div class="">F</div>
</div>

Save the New Position

Give each item a data-sortable-update-url. When the item is dropped at a new index, the controller sends a POST request to that URL. Its position parameter holds the new position of the item, starting at 1. Set csrfToken to also send a CSRF token, as a _token parameter:

100%
Loading...
<ul data-controller="sortable" data-sortable-csrf-token-value="{{ csrf_token('reorder-tasks') }}" class="">
    <li data-sortable-update-url="/link/tasks/1/position" class="">Write the release notes</li>
    <li data-sortable-update-url="/link/tasks/2/position" class="">Review the pull requests</li>
    <li data-sortable-update-url="/link/tasks/3/position" class="">Tag the new version</li>
</ul>

In your app, generate each URL with path(), and update the position in the matching controller:

<ul data-controller="sortable" data-sortable-csrf-token-value="{{ csrf_token('reorder-tasks') }}">
    {% for task in tasks %}
        <li data-sortable-update-url="{{ path('app_task_position', {id: task.id}) }}">{{ task.title }}</li>
    {% endfor %}
</ul>
#[Route('/tasks/{id}/position', name: 'app_task_position', methods: ['POST'])]
#[IsCsrfTokenValid('reorder-tasks')]
public function position(Task $task, Request $request, EntityManagerInterface $entityManager): Response
{
    $task->setPosition($request->getPayload()->getInt('position'));
    $entityManager->flush();

    return new Response('', Response::HTTP_NO_CONTENT);
}

This controller only changes the position of the moved task, so two tasks can end up with the same position. Your app must also shift the positions of the other tasks, or save the whole order with a form, as shown in the next section. The position counts only the items rendered in the list, so on a paginated list you must add the number of items on the previous pages.

Set paramName to rename the position field. A value like task[position] nests the field the way a Symfony form names its fields.

When Turbo is loaded on the page, the request also accepts a Turbo Stream response, and Turbo renders it.

Submit the Order with a Form

To save the whole order at once instead of each move, put the list in a form. The browser submits the fields in the order they appear on the page, so a hidden field in each item sends the ids in their new order.

100%
Loading...
<form action="/link/tasks/order" method="post" class="">
    <input type="hidden" name="_token" value="{{ csrf_token('reorder-tasks') }}">
    <ul data-controller="sortable" class="">
        <li class="">
            <input type="hidden" name="tasks[]" value="1">
            Write the release notes
        </li>
        <li class="">
            <input type="hidden" name="tasks[]" value="2">
            Review the pull requests
        </li>
        <li class="">
            <input type="hidden" name="tasks[]" value="3">
            Tag the new version
        </li>
    </ul>
    <button type="submit" class="">Save the order</button>
</form>

The payload class receives the submitted ids through #[MapRequestPayload], and its constraints reject an empty list, duplicate ids, and ids that are not strings of digits (the ids arrive as strings, because the Serializer does not convert the items of an array).

use Symfony\Component\Validator\Constraints as Assert;

final class TaskOrder
{
    /**
     * @param list<string> $tasks
     */
    public function __construct(
        #[Assert\NotBlank]
        #[Assert\Unique]
        #[Assert\All(new Assert\Type('digit'))]
        public array $tasks,
    ) {
    }
}

The controller checks the CSRF token, receives the validated ids from first to last, and passes them to the repository.

#[Route('/tasks/order', name: 'app_task_order', methods: ['POST'])]
#[IsCsrfTokenValid('reorder-tasks')]
public function order(#[MapRequestPayload] TaskOrder $order, TaskRepository $taskRepository): Response
{
    $taskRepository->reorder($order->tasks);

    return $this->redirectToRoute('app_task_index');
}

API Reference

data-controller="sortable"

Value Type Default
data-sortable-animation-value 
Number 150
data-sortable-handle-value 
String -
data-sortable-draggable-value 
String -
data-sortable-filter-value 
String -
data-sortable-param-name-value 
String 'position'
data-sortable-csrf-token-value 
String -