Foundations

Reordering

Putting the items of a row in another order, by a drag or a key, and keeping the order as data.

Markdown

Reordering lets people put things laid side by side — the columns of a table, tabs, the items of a toolbar — in the order that suits their work. The order is theirs: it changes what they see, not what the things are.

Moving columns

Drag a column's header past the middle of its neighbour. Or press the up arrow in the first row to reach a header, then Alt with an arrow to the side; the Menu key opens the column's menu, which moves it too.

Select all
Name
Price
In stock
Status
Released
Featured
1
$8.00
0
Draft
2
Rust Kettle 1
$35.61
161
Draft
Feb 6, 2026
3
Ink Mug 1
$10.26
226
In review
Aug 7, 2026
4
Oatmeal Saucer 1
$37.87
387
In review
Sep 12, 2026
5
Rust Tray 1
$12.52
52
Live
Mar 13, 2026
6
Ink Jug 1
$77.17
117
Draft
Aug 14, 2026
7
Oatmeal Bowl 1
$14.78
278
Draft
Oct 19, 2026
8
Rust Plate 1
$79.43
343
In review
Mar 20, 2026

12 rows · 0 edits

Code
"use client";

import { ProductGrid } from "./products.tsx";

export default function Sheet() {
  return <ProductGrid count={12} height={300} />;
}

Best practices

Keep the order as data. An order is a list of keys, apart from the things it orders, so that it can be remembered, restored and shared. Let its owner hold it, and tell the owner each time it is changed.

Let an order be incomplete. What an order does not name comes after what it does, as it came. An order saved before a column existed still places the others, and the new column is not lost; a key that names nothing any more is passed over.

Move by the middle. An item being dragged lands past a neighbour once the pointer is past that neighbour's middle, and not before — the same going either way, and whatever the widths. Dragged beyond either end, it lands at that end.

Show where it would land. While an item is dragged, mark the place it would take. Letting go puts it there; Escape, before letting go, leaves everything where it was.

Tell a press from a drag. A press that does not move is still a press, and does what a press does. One that has moved a few pixels is a drag, and the click that ends it is not a press on the item.

Move by a key, a place at a time. With focus on an item, Alt with an arrow moves it one place that way, and focus goes with it. Alt with an arrow is how an editor moves the line the caret is in, and it leaves the plain arrows to move focus. Say where the item now is to people who cannot see it move.

Offer it by name where there is no drag. A finger's drag across a row of headers scrolls them. Under touch, and for assistive technology, the item's menu moves it: before the item before it, or after the one after.

Mirror it right to left. Where items run from the right, leftward is onward: the arrows, and a drag, follow the way the items are laid.

Take what refers to an item with it. What is selected, or active, in a column stays with that column when the column moves.

Conventions

IntentPointerKeyboardTouch
Move an itemDrag itAlt with an arrow, on itIts menu
Take back a drag—Escape—

See also Keyboard navigation.

Behaviour

An order

Items are shown in the order their keys are listed in
expect(arrange(["a", "b", "c"], ["c", "a", "b"], self)).toEqual(["c", "a", "b"]);
An item the order does not name comes after those it does, and such items keep the order they came in
expect(arrange(["a", "b", "c", "d"], ["c"], self)).toEqual(["c", "a", "b", "d"]);
A key that names no item is passed over, and one listed twice counts once
expect(arrange(["a", "b"], ["x", "b", "b", "a"], self)).toEqual(["b", "a"]);
With no order, or one that changes nothing, the items are the very list given
const items = ["a", "b", "c"];
expect(arrange(items, undefined, self)).toBe(items);
expect(arrange(items, ["a", "b"], self)).toBe(items);
An order loses no item and makes none
const keys = fc.uniqueArray(fc.constantFrom("a", "b", "c", "d", "e", "f"));
fc.assert(
  fc.property(keys, fc.array(fc.constantFrom("a", "b", "c", "d", "e", "f", "x")), (items, order) => {
    const arranged = arrange(items, order, self);
    return arranged.length === items.length && items.every((item) => arranged.includes(item));
  }),
);

A move

An item moved takes the place it is moved to, and those between close up behind it
expect(moveItem(["a", "b", "c", "d"], 0, 2)).toEqual(["b", "c", "a", "d"]);
expect(moveItem(["a", "b", "c", "d"], 3, 1)).toEqual(["a", "d", "b", "c"]);
A move past either end stops at the end
expect(moveItem(["a", "b", "c"], 1, 9)).toEqual(["a", "c", "b"]);
expect(moveItem(["a", "b", "c"], 1, -4)).toEqual(["b", "a", "c"]);
A move to where the item is, or of an item that is not there, returns the very list given
const items = ["a", "b", "c"];
expect(moveItem(items, 1, 1)).toBe(items);
expect(moveItem(items, 5, 0)).toBe(items);
expect(moveItem(items, 2, 7)).toBe(items);

A drag

An item dragged within its own bounds stays where it is
expect(landingPlace(starts, 1, 120)).toBe(1);
expect(landingPlace(starts, 0, 0)).toBe(0);
It lands past a neighbour once the pointer is past the neighbour's middle, and not before
// The third item is 200 wide, from 150: its middle is at 250.
expect(landingPlace(starts, 1, 249)).toBe(1);
expect(landingPlace(starts, 1, 251)).toBe(2);
// Going back, the first item's middle is at 50.
expect(landingPlace(starts, 1, 51)).toBe(1);
expect(landingPlace(starts, 1, 49)).toBe(0);
Dragged beyond either end, it lands at that end
expect(landingPlace(starts, 1, -500)).toBe(0);
expect(landingPlace(starts, 1, 5000)).toBe(3);
The place it lands is always a place in the list
fc.assert(
  fc.property(fc.nat(3), fc.integer({ min: -100, max: 600 }), (from, position) => {
    const place = landingPlace(starts, from, position);
    return place >= 0 && place <= 3;
  }),
);

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/reorder.json
Source

src/primitives/reorder.ts

/**
 * Putting the items of a row in another order: columns, tabs, anything laid end
 * to end and dragged, or moved a place at a time by a key.
 *
 * - An order is a list of keys. It need not be complete: what it does not name
 *   comes after what it does, as it came, so that an order remembered from
 *   before an item existed still places the rest. A key that names nothing is
 *   passed over.
 * - An item dragged along the row lands past a neighbour once the pointer is
 *   past the neighbour's middle — the same going either way, whatever the
 *   widths.
 * - A move that changes nothing returns the list it was given.
 */

/** The items in the order their keys are listed in; those not listed after, as they came. */
export function arrange<T>(
  items: readonly T[],
  order: readonly string[] | undefined,
  keyOf: (item: T) => string,
): readonly T[] {
  if (order === undefined || order.length === 0) return items;
  const place = new Map<string, number>();
  for (const key of order) if (!place.has(key)) place.set(key, place.size);
  const last = place.size;
  // The sort is stable: items sharing the last place keep the order they came in.
  const placed = items.map((item) => ({ item, at: place.get(keyOf(item)) ?? last }));
  let moved = false;
  for (let index = 1; index < placed.length; index += 1) {
    if (placed[index]!.at < placed[index - 1]!.at) moved = true;
  }
  if (!moved) return items;
  return placed.toSorted((a, b) => a.at - b.at).map(({ item }) => item);
}

/** The list with the item at `from` taken to the place `to`, held within the list. */
export function moveItem<T>(items: readonly T[], from: number, to: number): readonly T[] {
  const last = items.length - 1;
  if (!Number.isInteger(from) || from < 0 || from > last) return items;
  const place = Math.min(Math.max(Math.trunc(to), 0), last);
  if (place === from) return items;
  const next = items.slice();
  next.splice(place, 0, ...next.splice(from, 1));
  return next;
}

/**
 * The place an item dragged to `position` takes among items laid end to end.
 * `starts[i]` is where item `i` begins, and one past the last is where they all
 * end; `position` is measured as they are. The answer is the item's place in
 * the list as it will be: its own, while it has passed no neighbour's middle.
 */
export function landingPlace(starts: ArrayLike<number>, from: number, position: number): number {
  let place = 0;
  for (let index = 0; index + 1 < starts.length; index += 1) {
    if (index !== from && (starts[index]! + starts[index + 1]!) / 2 < position) place += 1;
  }
  return place;
}