Reorder

Reorder.Group and Reorder.Item create drag-to-reorder lists and wrapped grids — sortable tabs, dashboards, playlists — with a couple of lines of markup. The group detects its layout axis, dragged items stay pinned under the cursor, and siblings spring out of the way.

<script lang="ts">
    import { Reorder } from '@humanspeak/svelte-motion'

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

<Reorder.Group values={items} onReorder={(next) => (items = next)}>
    {#each items as item (item)}
        <Reorder.Item value={item}>{item}</Reorder.Item>
    {/each}
</Reorder.Group>
<script lang="ts">
    import { Reorder } from '@humanspeak/svelte-motion'

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

<Reorder.Group values={items} onReorder={(next) => (items = next)}>
    {#each items as item (item)}
        <Reorder.Item value={item}>{item}</Reorder.Item>
    {/each}
</Reorder.Group>
mode · live running

Drag items to reorder the list

  • 🍅 Tomato
  • 🥒 Cucumber
  • 🧀 Cheese
  • 🥬 Lettuce

Usage

Every reorderable list needs three things wired together:

  1. values — the array driving the list, passed to Reorder.Group.
  2. onReorder — a callback that receives the new order. Assign it back to the state that renders the list.
  3. value — each Reorder.Item declares which entry it represents. Use the same value as the {#each} key.
<script lang="ts">
    import { Reorder } from '@humanspeak/svelte-motion'

    let tabs = $state(['Home', 'Search', 'Library'])
</script>

<Reorder.Group axis="x" values={tabs} onReorder={(next) => (tabs = next)}>
    {#each tabs as tab (tab)}
        <Reorder.Item value={tab}>{tab}</Reorder.Item>
    {/each}
</Reorder.Group>
<script lang="ts">
    import { Reorder } from '@humanspeak/svelte-motion'

    let tabs = $state(['Home', 'Search', 'Library'])
</script>

<Reorder.Group axis="x" values={tabs} onReorder={(next) => (tabs = next)}>
    {#each tabs as tab (tab)}
        <Reorder.Item value={tab}>{tab}</Reorder.Item>
    {/each}
</Reorder.Group>

The group renders a ul and items render li by default. Change either with as:

<Reorder.Group as="div" values={items} onReorder={handleReorder}>
    <Reorder.Item as="article" value={item} />
</Reorder.Group>
<Reorder.Group as="div" values={items} onReorder={handleReorder}>
    <Reorder.Item as="article" value={item} />
</Reorder.Group>

Axis

Omit axis to detect "x", "y", or "xy" from measured item geometry. Detection starts safely at "y" until enough items are measured. Pass an explicit axis when you want to override the layout:

<Reorder.Group axis="x" values={tabs} onReorder={setTabs} />
<Reorder.Group axis="x" values={tabs} onReorder={setTabs} />

Use axis="xy" for a wrapped layout. A single drag can move an item between columns and rows while displaced siblings animate into their new slots:

<Reorder.Group
    axis="xy"
    values={tiles}
    onReorder={(next) => (tiles = next)}
    style="display: flex; flex-wrap: wrap"
>
    {#each tiles as tile (tile.id)}
        <Reorder.Item value={tile}>{tile.label}</Reorder.Item>
    {/each}
</Reorder.Group>
<Reorder.Group
    axis="xy"
    values={tiles}
    onReorder={(next) => (tiles = next)}
    style="display: flex; flex-wrap: wrap"
>
    {#each tiles as tile (tile.id)}
        <Reorder.Item value={tile}>{tile.label}</Reorder.Item>
    {/each}
</Reorder.Group>

Explicit "x" and "y" axes lock item dragging to one direction. To let an item move freely while the group still reorders on one axis, pass drag to the item:

<Reorder.Item value={item} drag>{item}</Reorder.Item>
<Reorder.Item value={item} drag>{item}</Reorder.Item>

Layout animations

Items carry layout automatically, so displaced siblings FLIP to their new slots while the drag is live, and the dragged item springs into its new slot on release. If an item changes size while reordering, pass layout="position" to skip size projection:

<Reorder.Item value={item} layout="position">{item}</Reorder.Item>
<Reorder.Item value={item} layout="position">{item}</Reorder.Item>

Items float above their siblings while dragging via an automatic z-index. To make that work, items default to position: relative — override it via style if your layout needs something else, but keep items positioned so the floating z-index applies.

Scrollable lists

Lists taller than their container work out of the box — dragging an item near the container’s edge auto-scrolls it, so long lists can be traversed in a single gesture.

Mark the scrollable container with layoutScroll so layout measurements stay correct while it scrolls:

<script lang="ts">
    import { motion, Reorder } from '@humanspeak/svelte-motion'

    let items = $state([...Array(20).keys()])
</script>

<motion.div layoutScroll style="height: 300px; overflow-y: scroll">
    <Reorder.Group values={items} onReorder={(next) => (items = next)}>
        {#each items as item (item)}
            <Reorder.Item value={item}>{item}</Reorder.Item>
        {/each}
    </Reorder.Group>
</motion.div>
<script lang="ts">
    import { motion, Reorder } from '@humanspeak/svelte-motion'

    let items = $state([...Array(20).keys()])
</script>

<motion.div layoutScroll style="height: 300px; overflow-y: scroll">
    <Reorder.Group values={items} onReorder={(next) => (items = next)}>
        {#each items as item (item)}
            <Reorder.Item value={item}>{item}</Reorder.Item>
        {/each}
    </Reorder.Group>
</motion.div>

The window works as the scroll container too — lists below the fold reorder correctly at any scroll position, and dragging near the viewport edge scrolls the page.

For axis="xy", only the dominant velocity axis can auto-scroll on each drag frame. This prevents a diagonal gesture from double-scrolling horizontal and vertical containers; equal x/y speeds prefer vertical scrolling.

Right-to-left layouts

Horizontal and wrapped groups read direction from the rendered group. In an RTL region, visual insertion direction is reversed automatically while values remains in logical order:

<Reorder.Group dir="rtl" values={items} onReorder={setItems}>
    {#each items as item (item)}
        <Reorder.Item value={item}>{item}</Reorder.Item>
    {/each}
</Reorder.Group>
<Reorder.Group dir="rtl" values={items} onReorder={setItems}>
    {#each items as item (item)}
        <Reorder.Item value={item}>{item}</Reorder.Item>
    {/each}
</Reorder.Group>

Direction is read from the group itself, so nested LTR/RTL regions work without changing the document direction or reversing consumer data.

Observing the drag offset

Items accept external MotionValues for x/y through style, so you can observe or drive the live drag offset — for example to fade a shadow in while an item is lifted:

<script lang="ts">
    import { Reorder, useMotionValue, useTransform } from '@humanspeak/svelte-motion'

    const y = useMotionValue(0)
    const boxShadow = useTransform(y, (latest) =>
        `0 ${Math.min(Math.abs(latest) / 4, 8)}px 16px rgba(0,0,0,0.2)`
    )
</script>

<Reorder.Item value={item} style={{ y, boxShadow }}>{item}</Reorder.Item>
<script lang="ts">
    import { Reorder, useMotionValue, useTransform } from '@humanspeak/svelte-motion'

    const y = useMotionValue(0)
    const boxShadow = useTransform(y, (latest) =>
        `0 ${Math.min(Math.abs(latest) / 4, 8)}px 16px rgba(0,0,0,0.2)`
    )
</script>

<Reorder.Item value={item} style={{ y, boxShadow }}>{item}</Reorder.Item>

Drag props and callbacks

Items are motion components underneath — every drag prop (whileDrag, dragListener, dragControls, onDragStart, …) passes straight through:

<Reorder.Item
    value={item}
    whileDrag={{ scale: 1.03 }}
    onDragStart={() => (grabbed = item)}
    onDragEnd={() => (grabbed = null)}
>
    {item}
</Reorder.Item>
<Reorder.Item
    value={item}
    whileDrag={{ scale: 1.03 }}
    onDragStart={() => (grabbed = item)}
    onDragEnd={() => (grabbed = null)}
>
    {item}
</Reorder.Item>

dragSnapToOrigin is managed by Reorder itself — the “origin” an item snaps back to is its current slot, which updates as the order changes.

Keyboard

Every Reorder.Item can be reordered from the keyboard, with no opt-in (Motion 14.9; Home/End in 14.10). The group is a single tab stop (a roving tabindex), like a listbox or grid:

KeyAction
TabReach the group: the item focused last, or the first rendered item. One more Tab leaves the group
Arrow keys (nothing grabbed)Move focus to the next or previous item along the group’s axis
SpaceGrab the focused item, or drop it if it’s grabbed
Arrow keys (grabbed)Move the grabbed item one place along the group’s axis: Up/Down for y, Left/Right for x, all four for xy. Left and Right swap in RTL
Home / EndMove focus to the first or last item, or move the grabbed item there. Works on every axis
EscapeRestore the order from when the item was grabbed, then drop it
  • In a grid (axis="xy"), Up and Down go to the row drawn above or below, to the item nearest across, both for moving focus and for moving a grabbed item. Left and Right go along the order.
  • Only rendered items take focus: values that aren’t rendered (e.g. virtualized) are skipped, and if the tab stop’s item is unrendered the first rendered item takes over.
  • Enter isn’t handled, so it keeps its tap meaning (onTap, whileTap).
  • Arrows with Alt, Ctrl or Meta held, and keys pressed inside an item’s children (an <input>, say), are ignored. Arrows across the axis are left to the page.
  • Moving focus away (Tab, or clicking elsewhere) drops the item and keeps the current order.
  • Grabbing activates the item’s whileDrag state, so style the grabbed look with whileDrag. onDragStart and onDragEnd don’t fire for keyboard grabs.
  • Each move calls onReorder with the new order and the moved item, like a drag step. Escape calls it only if the order actually changed.

To turn keyboard reordering off for an item, call preventDefault() in its onkeydown:

<Reorder.Item value={item} onkeydown={(event) => event.preventDefault()}>
    {item}
</Reorder.Item>
<Reorder.Item value={item} onkeydown={(event) => event.preventDefault()}>
    {item}
</Reorder.Item>

A tabindex on an item overrides the group’s roving tab stop. tabindex={-1} only removes the item’s Tab stop: a click, an arrow key or focus() still focuses it, and Space then grabs it.

Moving items between groups

To drag items from one group into another, such as kanban columns, wrap the groups in Reorder.Board (Motion 14.10). See Reorder.Board for canMove, keyboard moves between groups and scrolling.

Notes

  • One-dimensional reordering moves values only while the pointer moves — a swap fires when the dragged item’s edge crosses the midpoint of its neighbor.
  • values can hold any type — strings, numbers, or objects (compared by reference). Keep the {#each} key and the item value in sync.
  • Wrapped axis="xy" layouts choose the closest visual row and horizontal slot from complete item measurements.

With LazyMotion

The Reorder from @humanspeak/svelte-motion renders motion components, which always have every feature. To take drag and layout from a <LazyMotion> instead (for example in a strict subtree), import Reorder from the m entry and give it domMax:

<script>
    import { LazyMotion, domMax } from '@humanspeak/svelte-motion'
    import { Reorder } from '@humanspeak/svelte-motion/m'
</script>

<LazyMotion features={domMax} strict>
    <Reorder.Group axis="y" values={items} onReorder={(next) => (items = next)}>
        <!-- Reorder.Item as usual -->
    </Reorder.Group>
</LazyMotion>
<script>
    import { LazyMotion, domMax } from '@humanspeak/svelte-motion'
    import { Reorder } from '@humanspeak/svelte-motion/m'
</script>

<LazyMotion features={domMax} strict>
    <Reorder.Group axis="y" values={items} onReorder={(next) => (items = next)}>
        <!-- Reorder.Item as usual -->
    </Reorder.Group>
</LazyMotion>

See LazyMotion for strict mode and feature bundles.

Props

Reorder.Group

PropTypeDefaultDescription
valuesV[]—The current order of item values (required)
onReorder(newOrder: V[], moved: V) => void—Fires with the new order and the moved item’s value after each swap (required)
axis'x' \| 'y' \| 'xy'detectedOverride the detected reorder axis; use 'xy' for wrapped layouts
asstring'ul'The HTML element to render

The moved item’s old and new positions are values.indexOf(moved) and newOrder.indexOf(moved). onReorder fires for each step of a drag, not once when it ends, so these positions describe that step.

All other motion props are forwarded to the underlying motion element.

Reorder.Item

PropTypeDefaultDescription
valueV—The entry in values this item represents (required)
asstring'li'The HTML element to render
layouttrue \| 'position'trueLayout animation mode while slots shuffle
dragboolean \| 'x' \| 'y'group axisOverride the axis lock (e.g. drag frees both axes)

All other motion props — including every drag prop and callback — are forwarded to the underlying motion element.

Reorder.Board

PropTypeDefaultDescription
canMove(value: V, from: V[], to: V[]) => boolean—Whether value may move from the group with values from to the group with values to. See Reorder.Board

Related


Based on Motion’s Reorder API.