# Sortable

Page: https://svelte-lean.vercel.app/primitives/sortable · Markdown: https://svelte-lean.vercel.app/primitives/sortable.md · Index: https://svelte-lean.vercel.app/llms.txt

A list whose items are reordered by dragging a handle, by Alt with the arrow keys or by move buttons. The list stays a native list: its document order is the value, and hidden inputs in the items submit it with a form.

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import is injected for the static data-slean="sortable".
	import '@svelte-lean/styles/sortable.css';
	import '@svelte-lean/styles/button.css';

	// The first render only: the behavior moves the item elements, hidden inputs included.
	const COLUMNS = [
		{ value: 'name', label: 'Name' },
		{ value: 'email', label: 'Email' },
		{ value: 'role', label: 'Role' },
		{ value: 'team', label: 'Team' },
		{ value: 'created', label: 'Created' }
	];

	let submitted = $state('');

	function submit(event: SubmitEvent) {
		event.preventDefault();
		const data = new FormData(event.currentTarget as HTMLFormElement);
		submitted = data.getAll('columns').join(', ');
	}
</script>

<form class="columns" onsubmit={submit}>
	<p id="sortable-columns-instructions" hidden>
		Press Alt and an arrow key to move an item. Drag a handle with a pointer.
	</p>
	<ul data-slean="sortable" aria-label="Columns">
		{#each COLUMNS as column (column.value)}
			<li data-slean-value={column.value}>
				<button
					type="button"
					data-slean-part="handle"
					aria-label="Move {column.label}"
					aria-describedby="sortable-columns-instructions"
					aria-keyshortcuts="Alt+ArrowUp Alt+ArrowDown Alt+Home Alt+End"
				></button>
				{column.label}
				<input type="hidden" name="columns" value={column.value} />
			</li>
		{/each}
	</ul>
	<div class="actions">
		<button type="submit" data-slean="button">Save order</button>
		<output>{submitted ? `Submitted: ${submitted}` : 'Not submitted yet'}</output>
	</div>
</form>

<style>
	.columns {
		display: grid;
		gap: var(--slean-space-3);
		max-inline-size: 22rem;
	}

	.actions {
		display: flex;
		flex-wrap: wrap;
		align-items: center;
		gap: var(--slean-space-3);
	}

	output {
		font-size: var(--slean-text-sm);
		color: var(--slean-fg-muted);
	}
</style>
```

Drag a handle, or focus it and press Alt with ArrowUp or ArrowDown, then save: the form submits the hidden inputs in their new order.

## Examples

### Move buttons

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import is injected for the static data-slean="sortable".
	import '@svelte-lean/styles/sortable.css';
	import { sortableParts } from '@svelte-lean/primitives/sortable';

	const steps = sortableParts('sortable-steps');
	const STEPS = [
		{ value: 'draft', label: 'Draft' },
		{ value: 'review', label: 'Legal review' },
		{ value: 'approve', label: 'Approval' },
		{ value: 'publish', label: 'Publish' }
	];
</script>

<p {...steps.instructions}>
	Press Alt and an arrow key, or use the arrow buttons, to move a step. Drag a handle with a
	pointer.
</p>
<ol class="steps" data-slean="sortable" aria-label="Approval steps">
	{#each STEPS as step (step.value)}
		<li {...steps.item(step.value)}>
			<button {...steps.handle(step.label)}></button>
			<span class="label">{step.label}</span>
			<button {...steps.previous(step.label)}></button>
			<button {...steps.next(step.label)}></button>
		</li>
	{/each}
</ol>

<style>
	.steps {
		max-inline-size: 22rem;
	}

	.label {
		flex: 1;
	}
</style>
```

The previous and next buttons move an item without a pointer or a key combination; the instructions name both ways.

### Horizontal

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import is injected for the static data-slean="sortable".
	import '@svelte-lean/styles/sortable.css';
	import { sortableParts } from '@svelte-lean/primitives/sortable';

	const tabs = sortableParts('sortable-tabs', { orientation: 'horizontal' });
	const TABS = ['General', 'Security', 'Billing', 'Members'];
</script>

<p {...tabs.instructions}>
	Press Alt and an arrow key to move a tab. Drag a handle with a pointer.
</p>
<!-- A row does not wrap: on a narrow screen it scrolls. -->
<div class="row">
	<ul data-slean="sortable" data-slean-orientation="horizontal" aria-label="Tab order">
		{#each TABS as tab (tab)}
			<li {...tabs.item(tab.toLowerCase())}>
				<button {...tabs.handle(tab)}></button>
				{tab}
			</li>
		{/each}
	</ul>
</div>

<style>
	.row {
		overflow-x: auto;
		padding-block: var(--slean-space-2);
	}
</style>
```

Alt with ArrowLeft or ArrowRight moves a tab; under RTL the row starts on the right and the arrows follow it.

### Application-rendered order

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import is injected for the static data-slean="sortable".
	import '@svelte-lean/styles/sortable.css';
	import { moveItem, sortableParts } from '@svelte-lean/primitives/sortable';
	import '@svelte-lean/styles/button.css';

	const sections = sortableParts('sortable-sections');
	const SECTIONS = [
		{ value: 'general', label: 'General' },
		{ value: 'security', label: 'Security' },
		{ value: 'billing', label: 'Billing' },
		{ value: 'members', label: 'Members' },
		{ value: 'limits', label: 'Limits' }
	];
	const label = (value: string) =>
		SECTIONS.find((section) => section.value === value)?.label ?? value;

	// The application owns the order; the behavior only asks for a move.
	let order = $state(SECTIONS.map((section) => section.value));
	let list: HTMLElement | undefined = $state();
</script>

<p {...sections.instructions}>
	Press Alt and an arrow key to move a section. Drag a handle with a pointer.
</p>
<ul
	class="sections"
	bind:this={list}
	data-slean="sortable"
	data-slean-render="app"
	data-slean-announce={'{label} is now {position} of {count}'}
	aria-label="Sections"
	onslean:change={(event) => {
		if (event.detail.behavior === 'sortable') order = event.detail.value;
	}}
>
	{#each order as value (value)}
		<li {...sections.item(value)}>
			<button {...sections.handle(label(value))}></button>
			{label(value)}
		</li>
	{/each}
</ul>
<div class="state">
	<button
		type="button"
		data-slean="button"
		data-variant="outlined"
		onclick={() => list && moveItem(list, 'limits', 0)}>Move Limits first</button
	>
	<p>State: <code>{JSON.stringify(order)}</code></p>
</div>

<style>
	.sections {
		max-inline-size: 22rem;
	}

	.state {
		display: flex;
		flex-wrap: wrap;
		align-items: center;
		gap: var(--slean-space-3);
		margin-block-start: var(--slean-space-3);
	}

	.state p {
		margin: 0;
		font-size: var(--slean-text-sm);
	}
</style>
```

With data-slean-render set to app the behavior moves nothing: the handler writes the order into state, the keyed each block moves the elements, and the behavior animates the drop and keeps focus. moveItem() asks for the same move from script.

## Helper or plain markup

Two ways to write the same markup: with `sortableParts()` or by hand. Both render the same HTML, which a test of this site compares; use either.

`With sortableParts()`

```svelte
<script lang="ts">
	import { sortableParts } from '@svelte-lean/primitives/sortable';
	const s = sortableParts('columns');
	const columns = [
		{ value: 'name', label: 'Name' },
		{ value: 'email', label: 'Email' }
	];
</script>

<p {...s.instructions}>
	Press Alt and an arrow key to move an item. Drag a handle with
	a pointer.
</p>
<ul data-slean="sortable" aria-label="Columns">
	{#each columns as column (column.value)}
		<li {...s.item(column.value)}>
			<button {...s.handle(column.label)}></button>
			{column.label}
			<button {...s.previous(column.label)}></button>
			<button {...s.next(column.label)}></button>
		</li>
	{/each}
</ul>
```

`Plain markup`

```html
<p id="columns-instructions" hidden>
	Press Alt and an arrow key to move an item. Drag a handle with
	a pointer.
</p>
<ul data-slean="sortable" aria-label="Columns">
	<li data-slean-value="name">
		<button
			type="button"
			data-slean-part="handle"
			aria-label="Move Name"
			aria-describedby="columns-instructions"
			aria-keyshortcuts="Alt+ArrowUp Alt+ArrowDown Alt+Home Alt+End"
		></button>
		Name
		<button
			type="button"
			data-slean-part="previous"
			aria-label="Move Name up"
		></button>
		<button
			type="button"
			data-slean-part="next"
			aria-label="Move Name down"
		></button>
	</li>
	<li data-slean-value="email">
		<button
			type="button"
			data-slean-part="handle"
			aria-label="Move Email"
			aria-describedby="columns-instructions"
			aria-keyshortcuts="Alt+ArrowUp Alt+ArrowDown Alt+Home Alt+End"
		></button>
		Email
		<button
			type="button"
			data-slean-part="previous"
			aria-label="Move Email up"
		></button>
		<button
			type="button"
			data-slean-part="next"
			aria-label="Move Email down"
		></button>
	</li>
</ul>
```

### What the helper does

- One id in, one reference out: `sortableParts('columns')` writes `columns-instructions` on the instructions and points every handle’s `aria-describedby` at it.
- Strings only: no handler, no state, no instance. The server and the browser render the same attributes, so hydration has nothing to patch.
- Each button gets `type="button"`, its part and a name from the item’s label (`Move Name`, `Move Name up`); `orientation: 'horizontal'` switches the arrow names to left and right and the handle’s `aria-keyshortcuts` to the left and right arrows; `messages` translates the words.

### Points to watch

- Render `s.instructions` once, beside the list and not inside it: a `<ul>` holds only `<li>`s.
- It registers nothing and imports no stylesheet: the root's `data-slean="sortable"` stay in your markup, where `@svelte-lean/vite` finds it.
- The id is not the list’s id: the helper derives only the instructions’ id from it, and the root needs none.
- Ids must be unique on the page. Hand-written markup works the same; the ids and the references between them are then yours to keep in step.

## Usage

Install `@svelte-lean/primitives` and `@svelte-lean/styles`. With `@svelte-lean/vite` the behavior registers itself for every `data-slean="sortable"`; without it, import the register module once.

```bash
npm install @svelte-lean/primitives
```

`+layout.svelte or any client module`

```ts
import '@svelte-lean/primitives/sortable/register';
```

`stylesheets`

```ts
import '@svelte-lean/styles/sortable.css';
```

Render a `<ul>` or `<ol>` with `data-slean="sortable"` and a name. Give every item a unique `data-slean-value` and a `handle` button named after the item, described by one hidden instructions paragraph beside the list. Add `previous` and `next` buttons for a way that needs neither a pointer nor a key combination.

Put a hidden input in each item to submit the order with a form. Listen to `slean:change` to store the order or to cancel a move. When the list is rendered from application state, set `data-slean-render="app"` and write `detail.value` into that state.

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<ul data-slean="sortable" aria-label>` | – | yes | Or \<ol\>. Options: data-slean-orientation (vertical, horizontal), data-slean-render="app", data-slean-announce. |
| item | `<li data-slean-value>` | – | yes | Every element child of the root with a unique data-slean-value. Optional data-slean-label names it in announcements. |
| handle | `<button type="button" aria-label aria-describedby aria-keyshortcuts data-slean-part="handle">` | `handle` | yes | One per item, named after it (Move Name). Pointer drags and the Alt keys start here. |
| previous | `<button type="button" aria-label data-slean-part="previous">` | `previous` | no | Moves the item one place back (up, or left in a left-to-right row). |
| next | `<button type="button" aria-label data-slean-part="next">` | `next` | no | Moves the item one place on. |
| instructions | `<p id hidden>` | – | yes | Beside the list, not inside it. Says how to move an item; every handle points at it with aria-describedby. |
| hidden input | `<input type="hidden" name value>` | – | no | Inside an item: it moves with the item, so a form submits the order. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `Alt+ArrowUp`/`Alt+ArrowDown` | focus on a handle | Moves the item one place back or on, in either orientation |
| `Alt+ArrowLeft`/`Alt+ArrowRight` | focus on a handle, horizontal list | Moves the item one place left or right; swapped under dir="rtl" |
| `Alt+Home`/`Alt+End` | focus on a handle | Moves the item to the first or the last place |
| `Enter`/`Space` | focus on a previous or next button | Moves the item one place back or on |
| `Escape` | during a pointer drag | Cancels the drag: every item slides back and nothing is reported |

## API

### Options

Attributes on the root, read by the behavior on every event.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| # `data-slean-orientation` | `'vertical' \| 'horizontal'` | `vertical` | A column of items, or a row: left and right arrows and a drag on the x axis. A wrapping row is not supported. |
| # `data-slean-render` | `'app'` | – | The application reorders its own keyed list: the behavior dispatches slean:change and moves nothing, then animates the drop and restores focus. |
| # `data-slean-announce` | `string` | – | What the live region says after a move, with {label}, {position} and {count} filled in; without it, “{label}, position {position} of {count}”. Translate it here. |
| # `data-slean-label` | `string` | – | On an item: its name in announcements, where the item’s text is longer than its name. Without it the text is used. |
| # `aria-disabled` | `'true'` | – | On the root: every press and key is ignored. On a handle, previous or next button (or disabled): that control is ignored. |

### States

Attributes the behavior writes during a pointer drag. Positions during the drag are inline `transform`s written through the CSSOM.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| # `data-slean-dragging` | `boolean attribute` | – | On the root, from lift until the drop has settled; the stylesheet raises the lifted item. |
| # `data-slean-settling` | `boolean attribute` | – | On the root for one frame, while settled positions are written without transitions. |
| # `data-slean-lifted` | `boolean attribute` | – | On the item under the pointer, and on the item gliding into its place after the drop. |

### Events

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| # `slean:change` | `CustomEvent<SortableChangeDetail>` | – | Dispatched from the root before an item moves (with data-slean-render="app", instead of moving it). Cancelable: preventDefault() moves nothing. |
| # `detail.behavior` | `'sortable'` | – | Narrows a handler shared by several primitives. |
| # `detail.value` | `string[]` | – | The items’ data-slean-value in the order the move asks for. |
| # `detail.previous` | `string[]` | – | The order before the move. |
| # `detail.from` | `number` | – | Index of the moved item in previous. |
| # `detail.to` | `number` | – | Index of the moved item in value. |

### Functions

From `@svelte-lean/primitives/sortable`; importing the module registers nothing.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| # `sortableParts(id, options?)` | `SortableParts` | – | The attributes of the instructions (\<id\>-instructions), item, handle, previous and next. options: orientation, messages. |
| # `sortableValue(root)` | `string[]` | – | The items’ data-slean-value in document order: the current value. |
| # `sortableItems(root)` | `HTMLElement[]` | – | The item elements in document order. |
| # `moveItem(root, value, to)` | `boolean` | – | Asks for the same move a key press makes: slean:change first, then the element unless data-slean-render="app". Returns whether the move went ahead. Nothing is announced. |

## Limits

One list at a time, moved on one axis. Not part of the primitive:

- dragging an item from one list to another (a kanban board);
- a two-dimensional grid or a wrapping row: an item is placed by its position on one axis;
- dragging a whole item without a handle, and dragging several selected items at once;
- persistence: listen to `slean:change` and store `detail.value`;
- a drop indicator other than the items moving out of the way.

## Styling

`sortable.css` lays the items out as bordered rows in a column, or in a row with `data-slean-orientation="horizontal"`, and draws the handle, previous and next buttons as icon buttons from the `--slean-icon-*` masks. During a drag the lifted item is raised with a shadow and the other items slide with a transition; coarse pointers get larger buttons. `data-color` on the root or an ancestor colors the focused handle and the lifted item’s border (primary by default). The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-space-1` | `calc(var(--slean-spacing) * 1)` | gap, padding-block, padding-inline |
| `--slean-fg` | `var(--slean-neutral-12)` | color |
| `--slean-space-2` | `calc(var(--slean-spacing) * 2)` | gap, padding-inline |
| `--slean-control-height-sm` | `2rem` | min-block-size, inline-size, block-size |
| `--slean-border` | `var(--slean-neutral-6)` | border |
| `--slean-radius-md` | `var(--slean-radius)` | border-radius |
| `--slean-surface` | `oklch(100% 0 0)` | background |
| `--slean-control-height-xs` | `1.75rem` | inline-size, block-size |
| `--slean-radius-sm` | `calc(var(--slean-radius) * 0.6)` | border-radius |
| `--slean-fg-muted` | `var(--slean-neutral-11)` | color |
| `--slean-duration-fast` | `100ms` | transition |
| `--slean-ease` | `cubic-bezier(0.2, 0, 0, 1)` | transition |
| `--slean-icon-grip` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M6 4h.01M10 4h.01M6 8h.01M10 8h.01M6 12h.01M10 12h.01' stroke='black' stroke-width='2.5' stroke-linecap='round'/%3E%3C/svg%3E")` | mask |
| `--slean-icon-chevron-up` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 10l4-4 4 4' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")` | mask-image |
| `--slean-icon-chevron-down` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 6l4 4 4-4' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")` | mask-image |
| `--slean-icon-chevron-left` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M10 4L6 8l4 4' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")` | mask-image |
| `--slean-icon-chevron-right` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M6 4l4 4-4 4' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")` | mask-image |
| `--slean-muted-hover` | `var(--slean-neutral-4)` | background |
| `--slean-focus-ring` | `var(--slean-focus-ring-width) solid var(--slean-focus-ring-color)` | outline |
| `--slean-focus-ring-offset` | `2px` | outline-offset |
| `--slean-color-main` | `var(--slean-primary-main)` | color, border-color |
| `--slean-duration-normal` | `160ms` | transition |
| `--slean-elevation-3` | `0 4px 12px oklch(0% 0 0 / 0.1), 0 1px 3px oklch(0% 0 0 / 0.08)` | box-shadow |

State selectors the stylesheet targets, all from the platform or ARIA: `:dir(rtl)`, `:disabled`, `:focus-visible`, `:hover`, `[aria-disabled="true"]`.

Variant attributes: `data-slean-orientation` (`horizontal`); `data-slean-value`; `data-slean-dragging`; `data-slean-settling`; `data-slean-lifted`.

## Controlled integration

Every move asks first: `slean:change` is cancelable, and cancelling it moves nothing (a dropped item slides back). A list the behavior moves itself needs no state; store `detail.value` to keep the order. With `data-slean-render="app"` write it into the state the keyed `{#each}` reads, as in the example above, and leave `animate:flip` off the items: the behavior already animates the drop.

`columns.svelte`

```svelte
<ul
	data-slean="sortable"
	aria-label="Columns"
	onslean:change={(event) => {
		if (event.detail.behavior !== 'sortable') return;
		// Keep the name column first: a cancelled move moves nothing, a dropped item slides back.
		if (event.detail.value[0] !== 'name') return event.preventDefault();
		localStorage.setItem('columns', JSON.stringify(event.detail.value));
	}}
>
	…
</ul>
```

## Under the hood

Runtime and size

- **Tier**: 2 · Lazy scoped controller
- **Behavior JS**: 3833 B brotli · 4279 B gzip · `packages/primitives/artifacts/size.json` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `<ul>/<ol>`, `<button>`, `setPointerCapture()`, `touch-action`, `moveBefore()`, `role="status"`
- **Shared listeners**: pointerdown, keydown, click
- **Per-instance listeners**: scoped to one interaction session
- **Lazy state**: WeakMap entry created on first interaction
- **Native base**: `<ul>/<ol> + <button> handles; document order is the value`

The sortable list registers one `pointerdown`, one `keydown` and one `click` handler with the shared router. During a pointer drag, and only then, the reorder engine holds `pointermove`, `pointerup`, `pointercancel`, a capturing `keydown` for Escape and the scroll of the scrolling container under one `AbortController`, with an auto-scroll timer; all are released when the press ends. One live region, created on the first announcement, speaks for every list of the page. A thousand lists keep one listener per type (`tests/sortable.test.ts`).

Why this implementation

The platform has no reorderable list. HTML drag and drop (`draggable`) has no keyboard path, no touch support in every engine, no preview of the drop position and no scroll near an edge, and the dragged item cannot be styled. The list stays a native `<ul>` or `<ol>` whose document order is the value; each item gets a native `<button>` handle, and a form receives the order through hidden inputs inside the items, without script.

Keys and buttons need no state: the behavior reads the item from the event target and moves the element, with `moveBefore()` where the browser has it, so focus and the state inside the item travel with it. A pointer drag is the one moment that needs memory: the shared reorder engine (`@svelte-lean/core/reorder`, also behind the DataTable row drag) reads the items’ boxes once at lift, holds its listeners for the press and releases them on drop. That scoped lifetime is what Tier 2 means here.

### The browser owns

- the list and its document order, which is the value
- the handles, previous and next buttons: focus, the tab order, Enter and Space
- pointer capture: the handle keeps receiving the drag outside its own box
- the hidden inputs that submit the order with the form
- moveBefore(), which moves an item without losing its focus or state, where the browser has it

### Svelte Lean owns

- Alt with the arrow keys, Home and End on a handle; mirrored in a right-to-left row
- a pointer drag that slides the other items out of the way before the drop, with auto-scroll near an edge and Escape to cancel, whose listeners live as long as the press
- the cancelable slean:change, and the move of the element unless data-slean-render="app"
- focus kept on the control that moved the item, and the glide of a dropped item into the place the application gave it
- one live region for the page that announces the new position
- sortable.css: the item rows, the grip and arrow icons, focus, the lifted item and the drag states

Accessibility

- The root is a list named with `aria-label` or `aria-labelledby`. Each handle is a button named after its item (`Move Name`), described by the instructions with `aria-describedby`, with the keys in `aria-keyshortcuts`.
- Alt with ArrowUp or ArrowDown moves the focused handle’s item one place; Alt with Home or End to the ends. Keys without Alt, or with Ctrl, Meta or Shift as well, are left to the page. Alt+ArrowUp and Alt+ArrowDown work in a horizontal list too: no browser binds them, while Alt+ArrowLeft is the history back key of Chromium and Firefox on Windows and Linux (the behavior cancels it only on a handle).
- `previous` and `next` are the move buttons of the WAI-ARIA rearrangeable listbox: a way to reorder that needs neither a pointer nor a key combination to be discovered.
- After every move one visually hidden `role="status"` region says `data-slean-announce` with the item’s label, position and count. Inside an open `<dialog>` the region is created in the dialog, since outside a modal dialog it would be inert.
- A moved item keeps focus on the control that moved it, also after an application re-render. Under `dir="rtl"` a horizontal row starts on the right: ArrowLeft moves an item on.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `Pointer events, pointer capture` | Widely available | Not applicable |
| `touch-action: none on the handle` | Widely available | Not applicable |
| `Element.moveBefore()` | Not Baseline | insertBefore(), and the behavior puts focus back on the control that had it |
| `role="status" live region` | Every engine and screen reader | Not applicable |

Without JavaScript

The list renders in its server order with its handles, which are buttons that do nothing, and a form submits that order through the hidden inputs. Previous and next buttons can work without script when they carry `name` and `value` and the server handles the submission.

Server rendering and hydration

Render the items in their current order with their values and handles. Nothing is measured on the server and no id is generated: the instructions' id comes from the author id given to `sortableParts()`.

Before the behavior loads, the handles and buttons are focusable but move nothing. The first event after hydration works on the server's markup.

Compatibility

Pointer events, pointer capture and `touch-action` are widely available. `Element.moveBefore()` is not Baseline: where it is missing the behavior uses `insertBefore()` and puts focus back on the control that had it. Cross-list drags, grids and wrapping rows are not part of the primitive (see Limits).

Tests and source

- `packages/primitives/tests/sortable.test.ts` Vitest: keys, RTL, buttons, cancelled and disabled moves, the live region, moveBefore() and insertBefore(), the form order, data-slean-render="app", a pointer drag, Escape, 1000 roots
- `packages/core/tests/reorder.test.ts` Vitest: the shared reorder engine the drag runs on (also used by the DataTable row drag)
- `apps/playground/tests/primitives/sortable.spec.ts` Playwright: keyboard moves, a real pointer drag, Escape, the application-rendered list, auto-scroll, routed listeners with 1 and 1000 roots
- `apps/playground/tests/primitives/a11y.spec.ts` axe on every playground page and on open dialog, popover and menu states
- `fixtures/sortable-only` consumer build asserting which modules ship with a static data-slean="sortable"
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)

- `packages/primitives/src/sortable/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/sortable/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/primitives/src/sortable/behavior.ts` the behavior definition, sortableValue(), sortableItems() and moveItem()
- `packages/primitives/src/sortable/parts.ts` sortableParts(), the attribute helper
- `packages/primitives/src/sortable/register.ts` the registration module
- `packages/core/src/reorder.ts` the shared reorder engine (@svelte-lean/core/reorder)
- `packages/styles/css/sortable.css` the optional stylesheet
