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>