Data

Sortable

A list whose order the user sets by dragging rows around, with the rows they pass sliding aside to open the gap. Reorders by pointer, by touch and by keyboard alike (space to pick up, arrows to move, space to drop), auto-scrolls when a drag reaches the edge of its container, and hands the whole reordered array back through `v-model`.

vue
<UiSortable :modelValue="..." />

Options

One block per prop — what it does, and what it looks like set.

Model value

required

modelValue is the list itself, bound with v-model — pick a row up, drag it past its neighbours, and the whole array comes back in its new order. Nothing is stored inside the component: the rows animate during the drag, but where they land is decided by the array you assign back.

  • Write the briefDue Monday
  • Review the draftWaiting on comments
  • Ship the releaseBlocked on QA
  • Run the retroFriday, 30 minutes

Order: Write the brief → Review the draft → Ship the release → Run the retro

There is no uncontrolled mode. The rows animate during a drag on their own, but the order that survives the drop is whatever array you assign back — bind it with v-model (or handle update:model-value yourself), because a list passed as :model-value alone will animate the move and then snap straight back to where it was.

One list, one order: this reorders within itself and has no notion of dragging a row into a *different* Sortable. A board of columns needs a component that owns the whole board, not several of these side by side.

Orientation

orientation is the axis rows travel along. horizontal lays them out in a row and drags them left and right — for column orders, tab strips, anything read across rather than down — and it swaps the arrow keys that move a picked-up item to match.

  • Name
  • Status
  • Owner
  • Due

Columns: Name, Status, Owner, Due

Handle

handle moves the drag onto a grip at the start of each row and leaves the rest of the row alone. Reach for it the moment rows contain their own controls — without it a press on the button below would be the start of a drag as easily as a click.

  • Opening theme
  • Second movement
  • Interlude
  • Finale

Even with the whole row draggable, a press that lands on an <a>, <button>, <input>, <select>, <textarea>, <label> or a contenteditable never starts a drag — those keep their click. Anything else that should behave the same way gets a data-sortable-ignore attribute. handle is still the better answer when a row is mostly controls: it's the one that also leaves the row's own text selectable.

Touch is where handle earns its keep. Without it, a finger has to rest on a row for about 200ms before the press counts as a drag rather than a scroll — that delay is what keeps the list scrollable — whereas the grip is touch-action: none and starts dragging immediately.

Item key

itemKey names the property Vue keys each row by. It matters as soon as a row holds state of its own: keyed by identity the text you typed travels with the field it belongs to, keyed by position it would stay behind on the row number instead. Type into a field below, then drag it somewhere else.

  • Email
  • Phone
  • Company

Variant

variant decides what surface each row brings. card rings and tints every row; solid, subtle and soft are the same three surfaces Card offers, so a list can match the cards around it; ghost draws nothing until hovered; plain does the same with a tighter gap, for a list already sitting inside a card of its own. On the lighter surfaces the grip steps up a shade so it stays visible.

card

  • Overview
  • Activity
  • Billing

solid

  • Overview
  • Activity
  • Billing

subtle

  • Overview
  • Activity
  • Billing

soft

  • Overview
  • Activity
  • Billing

ghost

  • Overview
  • Activity
  • Billing

plain, inside a card

  • Overview
  • Activity
  • Billing

Size

size is the padding inside each row — the same three steps the rest of the library uses. It changes the row height and nothing else; the drag geometry is measured off whatever the rows actually render at.

sm

  • First
  • Second

md

  • First
  • Second

lg

  • First
  • Second

Lock axis

lockAxis keeps the dragged row on its own axis, which is what makes a list feel like a list. Turn it off and the row follows the pointer in both directions — it reads as lifting the row off the stack rather than sliding it along, and the drop still lands on the axis either way.

  • Drag me anywhere
  • The drop still lands in the list
  • Only the preview leaves the axis

Disabled

disabled freezes the whole list — no drags, no keyboard pick-ups, no tab stops. A single item opts out on its own with disabled on the item instead, which stops it being picked up but doesn't pin it in place: its neighbours can still move around it, and its index changes when they do.

The whole list

  • Fixed order
  • Nothing here moves
  • Not even by keyboard

One item, via `disabled` on the item

  • Free to move
  • Pinned row
  • Also free

disabled on an *item* stops that row being picked up; it doesn't hold its index. Other rows still move around it, so a "pinned" row keeps its content but not its position — if a row genuinely has to stay first, keep it outside the sortable list.

Label

label is the list's accessible name, announced when focus first reaches a row. The default says only that the list is sortable, which is enough when there's one on the page and not nearly enough when there are two — name them after what they order.

  • Revenue
  • Signups
  • Errors

Nested

nested turns the list into a tree. Items name their parent, children hang off it on a line, and a drag has three outcomes: drop onto a row to nest the item under it, into a gap to place it there at that level, or below the last row to make it top-level. Children travel with their parent. By keyboard, ← takes an item out of its group and → tucks it under the item above.

  • Acme Group
  • Acme Tools
  • Acme Garden
  • Globex
  • Initech

The item that moved comes back as a COPY with its new parentKey value — every other item keeps its identity. Anything keyed on the item object itself (a WeakSet of open rows, say) loses that row after a move; key such state on itemKey instead.

Parents are matched by itemKey, so in a nested list it has to be unique and present on every item — an item without one falls back to its index, which no parentKey can point at.

Parent key

parentKey names the property that holds an item's parent — the parent's itemKey value, or nothing for a top-level item. It defaults to parentId; point it at whatever your data already calls the column, so the array can go back to a server unchanged.

  • docs
  • guides
  • src
  • tests

docs → root · docs/guides → docs · src → root · tests → root

Max depth

maxDepth caps how far below the top level a tree may go. With 1, items can have children but not grandchildren: dropping anything onto a child is refused, and so is nesting an item that already has children of its own, since its children would land a level too deep.

  • Engineering
  • Web
  • Design
  • Brand

Examples

The component in real use: compositions that answer "how do I build X with this", rather than one prop at a time.

Reordering without a mouse

Every row is a tab stop. Space or enter picks one up, the arrow keys move it — Home and End send it to either end — space or enter drops it, and escape puts it back where it started. The row follows along as you go, the list announces each move to a screen reader, and focus stays on the item after it lands, so a second move needs no re-aiming.

  • Install dependencies
  • Lint
  • Run tests
  • Build
  • Deploy

A list taller than the box it sits in

Drag a row to the top or bottom edge below and the container scrolls to meet it, faster the closer you get. The list finds the scrolling ancestor itself when the drag starts, so this needs nothing at the call site — and it falls back to scrolling the page when the list isn't inside a scroller of its own.

  • Render job 1Running
  • Render job 2Queued
  • Render job 3Queued
  • Render job 4Queued
  • Render job 5Queued
  • Render job 6Queued
  • Render job 7Queued
  • Render job 8Queued
  • Render job 9Queued
  • Render job 10Queued
  • Render job 11Queued
  • Render job 12Queued

The scrolling ancestor is resolved once, when the drag starts, by walking up from the list and taking the first element that actually overflows along the drag axis. A container that only becomes scrollable mid-drag won't be found, and a list with no scrolling ancestor auto-scrolls the page instead.

Rows of your own, through the `item` slot

The item slot replaces the row's contents and nothing else — the surface, the padding and the grip stay with the component, so a custom row still lines up with a plain one and still drags the same way. Anything focusable inside it is left clickable: presses that land on a button, link or input never start a drag, and data-sortable-ignore extends that to anything else.

  • AB
    Ada ByronStaff engineerOnsite
  • GM
    Grace MurrayCompiler engineerScreen
  • AM
    Alan MathisonResearch engineerOffer

Row geometry is measured once at pick-up and never re-read while the drag is running, since the transforms being written to those same rows would feed straight back into the measurement. Rows of different heights are fine — that's what the per-row measurement is for — but a row that resizes itself mid-drag will be a gap out of step until the drop.

Controls beside the row, through the `actions` slot

The actions slot renders next to each row, outside its surface — for a remove button that shouldn't read as part of the item it removes. It gets the same scope as item, including depth in a nested list.

  • Inbox
  • Drafts
  • Archive

Persisting the new order with `reorder`

update:model-value hands back the reordered array; reorder hands back the move that produced it — the item, the index it left and the index it landed on. That's the one to send to a server, since it's a description of what changed rather than a whole list to diff.

  • Applied
  • Screen
  • Onsite
  • Offer

Drag a stage to see the move that would be sent.


API Reference

Generated from the component's source — props, slots and emits as the component actually declares them.

Props

Prop
Type
Default
Description
modelValuerequired
SortableItem[]
—
The list being ordered, bound with `v-model` — a drop emits the whole array back in its new order. There's no uncontrolled mode: the array belongs to the caller, so a list bound with `:model-value` alone animates a drag and then snaps back.
"vertical" | "horizontal"
'vertical'
The axis rows are dragged along — a column reordered up and down, or a row of items reordered left and right. It also picks which arrow keys move a picked-up item. A `nested` list is always vertical.
boolean
false
Restricts dragging to a grip at the start of each row, leaving the rest of the row free to be clicked. Turn it on whenever rows contain their own buttons, links or inputs.
string
'id'
The property each item's render key is read from. Vue needs a key that travels with the item rather than with its position, so this should name something unique and stable; items without it fall back to their index, which reorders correctly but re-renders every row it passes. A `nested` list also matches `parentKey` values against it, so there it has to be unique.
"card" | "plain" | "solid" | "subtle" | "soft" | "ghost"
'card'
How each row's surface is drawn. `card` rings and tints every row; `solid`, `subtle` and `soft` are the same three surfaces as `Card`; `plain` and `ghost` draw no surface, only a hover tint — `plain` tightly spaced for a list that already sits inside a card, `ghost` with the regular gap.
"sm" | "md" | "lg"
'md'
The padding inside each row.
boolean
true
Confines the dragged row to `orientation`'s axis. Turn it off to let the row follow the pointer freely, which reads as picking it up off the list rather than sliding it along.
boolean
false
Disables the whole list, dragging and keyboard alike.
string
'Sortable list'
The accessible name announced for the list.
boolean
false
Turns the flat list into a tree. Each item may name its parent through `parentKey`; children render indented under it and joined to it by a line. A drag then drops an item ONTO another row to nest it there, into the gap between two rows to place it at that level, or below the last row to make it top-level — and its own children travel with it. By keyboard, ← and → move an item out of its group and under the item above.
string
'parentId'
The property holding an item's parent: the parent's `itemKey` value, or `null`/absent for a top-level item. A value that matches no item is treated as top-level. Only read when `nested`.
number
—
How many levels deep a `nested` list may go below the top level — `1` allows children but no grandchildren. A drop that would exceed it is refused. Unset, nesting is unlimited.

Slots

Slot
handle
Receives `{ dragging, disabled }`.
item
Receives `{ item, index, depth, dragging, disabled }`.
actions
Receives `{ item, index, depth, dragging, disabled }`.

Emits

Event
Payload
Description
update:modelValue
[items: SortableItem[]]
—
reorder
[event: SortableReorder]
The list settled in a new order. Not fired when a drag ends where it started. Flat lists only — a `nested` list fires `move`.
move
[event: SortableMove]
A `nested` list settled after an item moved — to a new position, a new parent, or both.
drag-start
[index: number]
An item was picked up, by pointer or by keyboard, with its index in `modelValue`.
drag-end
[index: number]
A drag ended, with the index the item settled at — the one it started from if it didn't move.

Types

ts
type Orientation = "vertical" | "horizontal";
ts
type Variant = "card" | "plain" | "solid" | "subtle" | "soft" | "ghost";
ts
type Size = "sm" | "md" | "lg";
ts
type Key = string | number;
ts
type DropTarget = { kind: "into" | "before"; index: number } | { kind: "end" };
ts
interface SortableItem {
    /**
     * Text shown by the built-in row. Ignored when you fill the `item` slot yourself, but still
     * worth setting — it's what a screen reader announces as the thing being moved.
     */
    label?: string;

    /**
     * Secondary text under the label in the built-in row.
     */
    description?: string;

    /**
     * Icon rendered ahead of the label in the built-in row.
     */
    icon?: string;

    /**
     * Stable identity for this item, read as the render key by the default `itemKey`.
     */
    id?: string | number;

    /**
     * Stops this item being picked up. It can still be displaced by its neighbours moving around
     * it — this pins the row, not its position.
     * @defaultValue false
     */
    disabled?: boolean;

    [key: string]: unknown;
}
ts
interface SortableReorder {
    /**
     * The item that moved.
     */
    item: SortableItem;

    /**
     * The index it was picked up from.
     */
    from: number;

    /**
     * The index it was dropped at.
     */
    to: number;
}
ts
interface SortableMove {
    /**
     * The item that moved, as it is in the emitted array — with its new `parentKey` value.
     */
    item: SortableItem;

    /**
     * Its new parent, or `null` when it now sits at the top level.
     */
    parent: SortableItem | null;

    /**
     * Its index in `modelValue` before the move.
     */
    from: number;

    /**
     * Its index in the emitted array.
     */
    to: number;
}
ts
interface Row {
    item: SortableItem;
    /** Index in `modelValue`. */
    index: number;
    depth: number;
    /** No later sibling: the tree line stops at this row's elbow. */
    last: boolean;
    /** Per ancestor level (`level + 1`): whether that ancestor has a later sibling, i.e. a line passes this row there. */
    trail: boolean[];
}
ts
interface Span {
    start: number;
    size: number;
}