sveltelean Primitives
Version v0.17.4
Report columns in a form
  • Name
  • Email
  • Role
  • Team
  • Created
Not submitted yet
Code
<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

Approval steps
  1. Draft
  2. Legal review
  3. Approval
  4. Publish
Code
<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

Tab order
  • General
  • Security
  • Billing
  • Members
Code
<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

Sections in application state
  • General
  • Security
  • Billing
  • Members
  • Limits

State: ["general","security","billing","members","limits"]

Code
<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()
<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
<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.

npm install @svelte-lean/primitives
+layout.svelte or any client module
import '@svelte-lean/primitives/sortable/register';
stylesheets
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

PartElementdata-slean-partRequiredNotes
root<ul data-slean="sortable" aria-label>–yesOr <ol>. Options: data-slean-orientation (vertical, horizontal), data-slean-render="app", data-slean-announce.
item<li data-slean-value>–yesEvery 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">handleyesOne 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">previousnoMoves the item one place back (up, or left in a left-to-right row).
next<button type="button" aria-label data-slean-part="next">nextnoMoves the item one place on.
instructions<p id hidden>–yesBeside 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>–noInside an item: it moves with the item, so a form submits the order.

Keyboard

KeyWhenResult
Alt+ArrowUp/Alt+ArrowDownfocus on a handleMoves the item one place back or on, in either orientation
Alt+ArrowLeft/Alt+ArrowRightfocus on a handle, horizontal listMoves the item one place left or right; swapped under dir="rtl"
Alt+Home/Alt+Endfocus on a handleMoves the item to the first or the last place
Enter/Spacefocus on a previous or next buttonMoves the item one place back or on
Escapeduring a pointer dragCancels the drag: every item slides back and nothing is reported

API

Options

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

NameTypeDefaultDescription
# data-slean-orientation 'vertical' | 'horizontal'verticalA 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 transforms written through the CSSOM.

NameTypeDefaultDescription
# 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

NameTypeDefaultDescription
# 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.

NameTypeDefaultDescription
# 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:

TokenDefault (light)Applies to
--slean-space-1calc(var(--slean-spacing) * 1)gap, padding-block, padding-inline
--slean-fgvar(--slean-neutral-12)color
--slean-space-2calc(var(--slean-spacing) * 2)gap, padding-inline
--slean-control-height-sm2remmin-block-size, inline-size, block-size
--slean-bordervar(--slean-neutral-6)border
--slean-radius-mdvar(--slean-radius)border-radius
--slean-surfaceoklch(100% 0 0)background
--slean-control-height-xs1.75reminline-size, block-size
--slean-radius-smcalc(var(--slean-radius) * 0.6)border-radius
--slean-fg-mutedvar(--slean-neutral-11)color
--slean-duration-fast100mstransition
--slean-easecubic-bezier(0.2, 0, 0, 1)transition
--slean-icon-gripurl("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-upurl("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-downurl("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-lefturl("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-righturl("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-hovervar(--slean-neutral-4)background
--slean-focus-ringvar(--slean-focus-ring-width) solid var(--slean-focus-ring-color)outline
--slean-focus-ring-offset2pxoutline-offset
--slean-color-mainvar(--slean-primary-main)color, border-color
--slean-duration-normal160mstransition
--slean-elevation-30 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
<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
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
FeatureBaselineOutside the target
Pointer events, pointer captureWidely availableNot applicable
touch-action: none on the handleWidely availableNot applicable
Element.moveBefore()Not BaselineinsertBefore(), and the behavior puts focus back on the control that had it
role="status" live regionEvery engine and screen readerNot 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