Reorder

Drag-to-reorder lists, tabs and grids.

The ReorderGroup and ReorderItem components create drag-to-reorder layouts, like reorderable tabs, to-do lists or grids.

<script lang="ts">
	import { ReorderGroup, ReorderItem } from "motion-sv";

	let items = $state(["Tomato", "Cucumber", "Cheese", "Lettuce"]);
</script>

<ReorderGroup bind:values={items}>
	{#each items as item (item)}
		<ReorderItem value={item}>{item}</ReorderItem>
	{/each}
</ReorderGroup>
<script lang="ts">
	import { ReorderGroup, ReorderItem } from "motion-sv";

	let items = $state(["Tomato", "Cucumber", "Cheese", "Lettuce"]);
</script>

<div class="list">
	<ReorderGroup bind:values={items}>
		{#each items as item (item)}
			<ReorderItem value={item}>{item}</ReorderItem>
		{/each}
	</ReorderGroup>
</div>

<style>
	.list {
		width: 280px;
	}

	.list :global(ul) {
		display: flex;
		flex-direction: column;
		gap: 8px;
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.list :global(li) {
		position: relative;
		margin: 0;
		padding: 14px 18px;
		border-radius: 10px;
		background: #ff3e00;
		color: white;
		font-weight: 600;
		cursor: grab;
	}
</style>

Usage

Import

import { ReorderGroup, ReorderItem } from "motion-sv";

Groups and items

Every reorderable layout is wrapped in a ReorderGroup. It renders a <ul> by default, which you can change with the as prop:

<ReorderGroup as="ol" bind:values={items}>

Pass the group the array of values in the layout with values. When an item is dragged over the middle of its neighbour, the group puts the values in their new order. Bind them with bind:values, and your state stays in sync:

<script lang="ts">
	let items = $state([0, 1, 2, 3]);
</script>

<ReorderGroup bind:values={items}>
	<!-- ... -->
</ReorderGroup>

Or pass values and an onReorder callback, which receives the new order, to update your state yourself:

<ReorderGroup values={items} onReorder={(newOrder) => (items = newOrder)}>
	<!-- ... -->
</ReorderGroup>

Render each item with a ReorderItem, passing it the value it represents with value. Each value must be an entry of the group's values. Render the items in a keyed {#each} block, so each element moves along with its value:

<ReorderGroup bind:values={items}>
	{#each items as item (item)}
		<ReorderItem value={item}>{item}</ReorderItem>
	{/each}
</ReorderGroup>

Drag axis

ReorderGroup detects whether its items are laid out horizontally, vertically or across both directions, and sets the axis of reordering to match: "x" for rows, "y" for columns and "xy" for grids and wrapping lists. Set the axis prop to override it:

<ReorderGroup axis="xy" bind:values={items} class="grid">
	{#each items as item (item)}
		<ReorderItem value={item}>{item}</ReorderItem>
	{/each}
</ReorderGroup>
<script lang="ts">
	import { ReorderGroup, ReorderItem } from "motion-sv";

	const hues = ["#ff0088", "#dd00ee", "#9911ff", "#1e75f7", "#0cdcf7", "#8df0cc"];

	let items = $state(Array.from({ length: 16 }, (_, index) => index + 1));
</script>

<div class="reorder-stage">
	<ReorderGroup as="div" bind:values={items} class="reorder-grid" aria-label="Reorderable grid">
		{#each items as item (item)}
			<ReorderItem
				as="div"
				value={item}
				class="reorder-item"
				transition={{ type: "spring", stiffness: 350, damping: 30 }}
				whileDrag={{ scale: 1.08 }}
				style={{ backgroundColor: hues[item % 6] }}
			>
				{String(item).padStart(2, "0")}
			</ReorderItem>
		{/each}
	</ReorderGroup>
</div>

<style>
	.reorder-stage {
		display: flex;
		justify-content: center;
		align-items: center;
		touch-action: none;
	}

	.reorder-stage :global(.reorder-grid) {
		display: grid;
		grid-template-columns: repeat(4, minmax(0, 1fr));
		gap: 8px;
		width: min(76vw, 420px);
	}

	.reorder-stage :global(.reorder-item) {
		display: grid;
		place-items: center;
		aspect-ratio: 1;
		color: #0b1012;
		font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
		font-size: clamp(11px, 2.2vw, 15px);
		font-weight: 600;
		cursor: grab;
		user-select: none;
	}

	.reorder-stage :global(.reorder-item:active) {
		cursor: grabbing;
	}
</style>

When the axis is "x" or "y", items only move along that axis. To let them move in both directions, while still reordering along the axis, pass drag to the items:

<ReorderItem value={item} drag>{item}</ReorderItem>

Dragging state

ReorderItem's children are a snippet that receives whether the item is being dragged:

<ReorderItem value={item}>
	{#snippet children(isDragging)}
		{item}
		{#if isDragging}<span class="badge">Dragging</span>{/if}
	{/snippet}
</ReorderItem>

ReorderItem is a motion component, so it also supports whileDrag and the drag callbacks, like onDragStart and onDragEnd:

<ReorderItem
	value={item}
	style={{ boxShadow: "0 0 0 rgb(0 0 0 / 0)" }}
	whileDrag={{ scale: 1.04, boxShadow: "0 8px 24px rgb(0 0 0 / 0.25)" }}
>
	<!-- ... -->
</ReorderItem>
<script lang="ts">
	import { ReorderGroup, ReorderItem } from "motion-sv";

	let items = $state(["Red", "Green", "Blue"]);
</script>

<div class="list">
	<ReorderGroup bind:values={items}>
		{#each items as item (item)}
			<ReorderItem
				value={item}
				style={{ boxShadow: "0 0 0 rgb(0 0 0 / 0)" }}
				whileDrag={{ scale: 1.04, boxShadow: "0 8px 24px rgb(0 0 0 / 0.25)" }}
			>
				{#snippet children(isDragging)}
					{item}
					<span class={["badge", { visible: isDragging }]}>Dragging</span>
				{/snippet}
			</ReorderItem>
		{/each}
	</ReorderGroup>
</div>

<style>
	.list {
		width: 280px;
	}

	.list :global(ul) {
		display: flex;
		flex-direction: column;
		gap: 8px;
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.list :global(li) {
		position: relative;
		display: flex;
		align-items: center;
		justify-content: space-between;
		margin: 0;
		padding: 14px 18px;
		border-radius: 10px;
		background: #8df0cc;
		color: #0f172a;
		font-weight: 600;
		cursor: grab;
	}

	.badge {
		padding: 2px 8px;
		border-radius: 999px;
		background: rgb(15 23 42 / 0.15);
		font-size: 12px;
		opacity: 0;
		transition: opacity 0.2s;
	}

	.badge.visible {
		opacity: 1;
	}
</style>

z-index

ReorderItem sets a z-index on the dragged item, so it appears above the others. z-index only affects elements whose position isn't static, so give the items position: relative (or absolute).

Layout animations

Items animate their layout: when the group reorders them, they slide into their new places. They animate their size as well. To only animate their position, set layout to "position":

<ReorderItem value={item} layout="position">{item}</ReorderItem>

Adding and removing items

Wrap the items in an AnimatePresence to animate them as they're added and removed. Removed items play their exit animation, and the items around them move into place:

<ReorderGroup bind:values={items}>
	<AnimatePresence>
		{#each items as item (item)}
			<ReorderItem
				value={item}
				initial={{ opacity: 0 }}
				animate={{ opacity: 1 }}
				exit={{ opacity: 0 }}
			>
				{item}
			</ReorderItem>
		{/each}
	</AnimatePresence>
</ReorderGroup>

The group measures its items whenever values changes, so when an item is added, the others move into place too.

Elements outside the group that move with the list, like a button below it, are layout animations of your own. Render them through a layout namespace, and wrap them and the group in a LayoutGroup, so they're measured along with the items:

<script lang="ts">
	import { AnimatePresence, LayoutGroup, ReorderGroup, ReorderItem, createLayoutMotion } from "motion-sv";

	const layout = createLayoutMotion();

	let nextId = 4;
	let items = $state([1, 2, 3]);
</script>

<LayoutGroup>
	<ReorderGroup bind:values={items}>
		<AnimatePresence initial={false}>
			{#each items as id (id)}
				<ReorderItem value={id} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
					Item {id}
					<button onclick={() => (items = items.filter((item) => item !== id))}>Remove</button>
				</ReorderItem>
			{/each}
		</AnimatePresence>
	</ReorderGroup>
	<layout.button onclick={() => (items = [nextId++, ...items])}>Add item</layout.button>
</LayoutGroup>
<script lang="ts">
	import { AnimatePresence, LayoutGroup, ReorderGroup, ReorderItem, createLayoutMotion } from "motion-sv";

	const fruits = ["Apple", "Banana", "Cherry", "Grape", "Kiwi", "Lemon"];

	// The add button moves with the list: the LayoutGroup measures it along with the items.
	const layout = createLayoutMotion();

	let items = $state(fruits.slice(0, 3));

	function add() {
		const next = fruits.find((fruit) => !items.includes(fruit));
		if (next) items = [next, ...items];
	}

	const remove = (item: string) => (items = items.filter((other) => other !== item));
</script>

<div class="list">
	<LayoutGroup>
		<ReorderGroup bind:values={items}>
			<AnimatePresence initial={false}>
				{#each items as item (item)}
					<ReorderItem
						value={item}
						initial={{ opacity: 0, scale: 0.9 }}
						animate={{ opacity: 1, scale: 1 }}
						exit={{ opacity: 0, scale: 0.9 }}
					>
						{item}
						<button onclick={() => remove(item)} aria-label="Remove {item}">×</button>
					</ReorderItem>
				{/each}
			</AnimatePresence>
		</ReorderGroup>
		<layout.button class="add" onclick={add} disabled={items.length === fruits.length}>Add item</layout.button>
	</LayoutGroup>
</div>

<style>
	.list {
		display: flex;
		flex-direction: column;
		gap: 12px;
		width: 280px;
	}

	.list :global(ul) {
		display: flex;
		flex-direction: column;
		gap: 8px;
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.list :global(li) {
		position: relative;
		display: flex;
		align-items: center;
		justify-content: space-between;
		margin: 0;
		padding: 10px 10px 10px 18px;
		border-radius: 10px;
		background: #9911ff;
		color: white;
		font-weight: 600;
		cursor: grab;
	}

	.list :global(li button) {
		width: 28px;
		height: 28px;
		border: none;
		border-radius: 6px;
		background: rgb(255 255 255 / 0.2);
		color: inherit;
		font: inherit;
		cursor: pointer;
	}

	.list :global(.add) {
		padding: 8px 16px;
		border: none;
		border-radius: 8px;
		background: rgb(153 17 255 / 0.2);
		color: inherit;
		font: inherit;
		cursor: pointer;
	}

	.list :global(.add:disabled) {
		opacity: 0.5;
		cursor: default;
	}
</style>

Drag handles

By default, pressing anywhere on a ReorderItem starts a drag. To drag it from a handle instead, start the drag with useDragControls, and set dragListener to false so the rest of the item doesn't start one.

Each item needs its own drag controls, so make the item a component:

<!-- item.svelte -->
<script lang="ts">
	import { ReorderItem, useDragControls } from "motion-sv";

	let { item }: { item: string } = $props();

	const controls = useDragControls();
</script>

<ReorderItem value={item} dragListener={false} dragControls={controls}>
	{item}
	<div class="handle" onpointerdown={(event) => controls.start(event)}></div>
</ReorderItem>

<style>
	.handle {
		cursor: grab;
		touch-action: none;
	}
</style>
<ReorderGroup bind:values={items}>
	{#each items as item (item)}
		<Item {item} />
	{/each}
</ReorderGroup>
<script lang="ts">
	import { ReorderGroup } from "motion-sv";
	import Item from "./drag-handle/item.svelte";

	let items = $state(["Write the docs", "Ship the release", "Celebrate"]);
</script>

<div class="list">
	<ReorderGroup bind:values={items}>
		{#each items as item (item)}
			<Item {item} />
		{/each}
	</ReorderGroup>
</div>

<style>
	.list {
		width: 280px;
	}

	.list :global(ul) {
		display: flex;
		flex-direction: column;
		gap: 8px;
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.list :global(li) {
		position: relative;
		display: flex;
		align-items: center;
		justify-content: space-between;
		margin: 0;
		padding: 8px 8px 8px 18px;
		border-radius: 10px;
		background: #0cdcf7;
		color: #0f172a;
		font-weight: 600;
	}
</style>

Give the handle touch-action: none, so touch devices don't scroll the page instead of dragging.

Scrollable lists

When the group is inside a scrollable element, dragging an item close to one of that element's edges scrolls it. The closer the pointer is to the edge, the faster it scrolls. When no element around the group scrolls, the page does.

<div style="height: 280px; overflow: auto">
	<ReorderGroup bind:values={items}>
		<!-- ... -->
	</ReorderGroup>
</div>

In this to-do list, ticking a task also moves it to the end, which the group animates like any other change to values:

<script lang="ts">
	import { LayoutGroup, ReorderGroup } from "motion-sv";
	import Item, { type TodoItem } from "./todo-list/item.svelte";

	const COLORS = ["#ff0088", "#dd00ee", "#9911ff", "#1e75f7", "#0cdcf7", "#8df0cc"];

	let todos = $state<TodoItem[]>([
		{ id: 0, text: "Review project proposal", completed: false, color: COLORS[0] },
		{ id: 1, text: "Update documentation", completed: false, color: COLORS[1] },
		{ id: 2, text: "Schedule team meeting", completed: false, color: COLORS[2] },
		{ id: 3, text: "Test new features", completed: false, color: COLORS[3] },
		{ id: 4, text: "Deploy to staging", completed: false, color: COLORS[4] },
		{ id: 5, text: "Send weekly report", completed: false, color: COLORS[5] },
		{ id: 6, text: "Prepare presentation slides", completed: false, color: COLORS[0] },
		{ id: 7, text: "Review pull requests", completed: false, color: COLORS[1] },
	]);

	function toggleTodo(id: number) {
		todos = todos.map((todo) => (todo.id === id ? { ...todo, completed: !todo.completed } : todo));

		if (todos.find((todo) => todo.id === id)?.completed) {
			// Move completed to end after a delay for animation
			setTimeout(() => {
				const completed = todos.filter((todo) => todo.completed);
				const uncompleted = todos.filter((todo) => !todo.completed);
				todos = [...uncompleted, ...completed];
			}, 600);
		}
	}
</script>

<div class="todo-container">
	<LayoutGroup>
		<ReorderGroup bind:values={todos} axis="y" class="todo-list" as="ul">
			{#each todos as todo (todo.id)}
				<Item value={todo} onToggle={() => toggleTodo(todo.id)} />
			{/each}
		</ReorderGroup>
	</LayoutGroup>
</div>

<style>
	.todo-container {
		width: 100%;
		max-width: 340px;
		height: 280px;
		padding: 24px;
		border: 1px solid #e5e7eb;
		border-radius: 16px;
		background-color: #ffffff;
		box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
		overflow: auto;
		scrollbar-width: thin;
		scrollbar-color: #d1d5db transparent;
	}

	.todo-container::-webkit-scrollbar {
		width: 6px;
	}

	.todo-container::-webkit-scrollbar-track {
		background: transparent;
	}

	.todo-container::-webkit-scrollbar-thumb {
		border-radius: 3px;
		background: #d1d5db;
	}

	.todo-container :global(.todo-list) {
		display: flex;
		flex-direction: column;
		gap: 12px;
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.todo-container :global(.todo-item) {
		display: flex;
		align-items: center;
		gap: 12px;
		margin: 0;
		padding: 14px 16px;
		border-radius: 10px;
		background-color: #f9fafb;
		cursor: grab;
	}
</style>

API

ReorderGroup

ReorderGroup accepts all motion component props, in addition to the following.

as

Default: "ul"

The element ReorderGroup renders.

<ReorderGroup as="div" bind:values={items}>

axis

Default: detected from the layout

The axis of reordering: "x", "y" or "xy". By default, it's detected from the layout of the items: "x" for rows, "y" for columns and "xy" for grids and wrapping lists.

values

The array of values to reorder. Each ReorderItem's value must be an entry of this array. Use bind:values to update your state with the new order.

onReorder

A callback that fires with the new order of values whenever the items reorder.

<ReorderGroup values={items} onReorder={(newOrder) => (items = newOrder)}>

ReorderItem

ReorderItem accepts all motion component props, in addition to the following.

as

Default: "li"

The element ReorderItem renders.

value

The value this item represents. When the items reorder, it takes the item's new place in the group's values.

layout

Default: true

Whether the item animates its size and position (true), or only its position ("position").

children

The item's content: a snippet that receives whether the item is being dragged.

<ReorderItem value={item}>
	{#snippet children(isDragging)}
		{item}{isDragging ? " (dragging)" : ""}
	{/snippet}
</ReorderItem>