Reorder

A recipe, not a package namespace. Each row of an ordered list has a grip to drag it by, through useReorder, and a Menu with Move to top, Move up, Move down and Move to bottom for the keyboard, screen readers and anyone who can't drag. The new position is announced with useAnnouncer either way. What the list holds and where its order is saved are up to your product. There is no Reorder.* export.

On this page

Drag a row by its grip, or open its menu and move it.

Pinned channels

  1. Design

  2. Engineering

  3. Marketing

  4. Sales

  5. Support

The move menu

Put the rows in an ol, since their order is the point, and give each row a Menu.Trigger named after the row. Moves the row can't make, such as Move up on the first row, take disabled, which a menu item draws as aria-disabled: the item stays in the arrow-key order and is read as unavailable, so the menu keeps the same four items on every row. Key each row by its item's id, not its index, so the row's menu moves with it and its focus return lands on the moved row. The example lists the four moves with their targets, and a move is unavailable where its target is the row's own place or off the list.

import { Button, Menu, Stack, Text, useAnnouncer, useReorder, type ReorderDrop } from '@tounsoo/todae';
import { EllipsisVertical, GripVertical } from 'lucide-react';
import { useId } from 'react';

function moveItem<T>(list: readonly T[], from: number, to: number): T[] {
  const next = [...list];
  const [item] = next.splice(from, 1);
  next.splice(to, 0, item!);
  return next;
}

// The grip spans the row's height, with padding beside the icon, so it is easy to hit: at least
// 24 by 24 CSS pixels (WCAG 2.2 SC 2.5.8).
const gripStyle = {
  display: 'flex',
  alignItems: 'center',
  alignSelf: 'stretch',
  paddingInline: 'var(--todae-space-control-x)',
  cursor: 'grab',
  touchAction: 'none',
  color: 'var(--todae-color-fg-muted)',
} as const;

// A line at the edge the dragged row would land on.
function dropLine(drop: ReorderDrop | null, index: number) {
  if (drop?.index !== index) return undefined;
  const width = 'var(--todae-focus-ring-width)';
  const offset = drop.position === 'before' ? width : `calc(-1 * ${width})`;
  return `inset 0 ${offset} 0 var(--todae-color-accent-default)`;
}

export function PinnedChannels({
  channels,
  onReorder,
}: {
  channels: readonly { id: string; name: string }[];
  onReorder: (channels: { id: string; name: string }[]) => void;
}) {
  const announce = useAnnouncer();
  const labelId = useId();
  const last = channels.length - 1;

  const move = (from: number, to: number) => {
    onReorder(moveItem(channels, from, to));
    announce(`${channels[from]!.name} moved to position ${to + 1} of ${channels.length}`);
  };
  const reorder = useReorder({ onMove: move });

  return (
    <>
      <Text.Root id={labelId} variant="label">
        Pinned channels
      </Text.Root>
      <Stack.Root as="ol" gap="none" aria-labelledby={labelId}>
        {channels.map((channel, index) => {
          const moves = [
            ['Move to top', 0],
            ['Move up', index - 1],
            ['Move down', index + 1],
            ['Move to bottom', last],
          ] as const;
          return (
            <li
              key={channel.id}
              {...reorder.getItemProps(index)}
              style={{
                boxShadow: dropLine(reorder.drop, index),
                background: reorder.dragging === index ? 'var(--todae-color-bg-subtle)' : undefined,
              }}
            >
              <Stack.Root direction="row" align="center" gap="condensed">
                <span
                  {...reorder.getHandleProps(index)}
                  aria-hidden
                  style={gripStyle}
                >
                  <GripVertical size="1em" />
                </span>
                <Text.Root style={{ flex: 1 }}>{channel.name}</Text.Root>
                <Menu.Root>
                  <Menu.Trigger variant="ghost" aria-label={`Move ${channel.name}`}>
                    <Button.Icon>
                      <EllipsisVertical />
                    </Button.Icon>
                  </Menu.Trigger>
                  <Menu.Content>
                    {moves.map(([label, to]) => (
                      <Menu.Item
                        key={label}
                        disabled={to === index || to < 0 || to > last}
                        onSelect={() => move(index, to)}
                      >
                        {label}
                      </Menu.Item>
                    ))}
                  </Menu.Content>
                </Menu.Root>
              </Stack.Root>
            </li>
          );
        })}
      </Stack.Root>
    </>
  );
}

When the menu closes, focus goes back to the trigger, which has moved with its row, and the browser scrolls it into view if it is out of view. The announcement says where the row went, which the person would otherwise have to find by reading the list again.

Dragging

useReorder puts the browser's drag and drop on the rows. Spread getItemProps(index) on each row and getHandleProps(index) on its grip: a press on the grip makes that row draggable, so text in the rows stays selectable and a row drags only by its grip. While a row is dragged, drop says which row it would land beside and on which side, and the example draws a line there from tokens. A drop calls the same move as the menu, so a drag is announced too. A drop outside the list leaves the order as it was.

The grip is aria-hidden and not a tab stop: dragging has no keyboard path, so the menu is the way to move a row from the keyboard (WCAG 2.2 SC 2.1.1), and it is the way to move one with a single pointer and no dragging (SC 2.5.7). The grip spans the row's height, with padding beside the icon, so it is easy to hit and at least 24 by 24 CSS pixels (SC 2.5.8). Give the grip touch-action: none, so a finger on it drags the row instead of scrolling the page. On a touch screen the hook drags with pointer events, since browsers differ there; the page doesn't scroll during that drag, so the menu is the way to move a row far in a long list. Escape cancels a drag and leaves the order as it was.

Saving the order

The example changes the list as soon as a move is chosen. If your product saves the new order and the save fails, put the list back and say so with a Toast or an Alert, since the announcement already said the row moved. React keeps focus on a row's trigger when it moves the row back, as it does for any focused element it moves in one render.

Accessible composition

Todae handles: Menu gives the trigger aria-haspopup and aria-expanded, moves focus into the menu and through it with arrow keys, reads a disabled item as aria-disabled, and returns focus to the trigger when an item is chosen. useReorder drags a row only by its grip, works out where a drop lands, and ignores drags from outside the list, such as files. useAnnouncer writes the message to a live region.

You supply: An ol with a name. A name for each row's trigger that says which row it moves. disabled on the moves a row can't make. Keys from the item's id. A grip hidden from screen readers, and a look for the drop line and the dragged row. The announcement, with the row's name and its new position, in your product's language.

Watch for: Keying rows by index, which hands the trigger's focus to whichever row now sits where the moved one was. Removing a move a row can't make instead of marking it unavailable, which changes the menu from row to row. A move that isn't announced, or an announcement that gives the position but not the row. Drag as the only way to move a row. A grip that is a button or a tab stop, which adds a control that does nothing from the keyboard. A move announced before a save that then fails: announce the row going back too.

When to use

When: A short list whose order the person sets, such as pinned items, a playlist, the columns of a table or the steps of a checklist.

When not: Sorting by a value such as a name or a date → a sortable column. Moving an item to another list or group → a menu item that names the destination, or a Dialog to pick one.