# Sortable

> A drag-to-reorder wrapper for lists and rows, with a Move menu on each handle for keyboard and screen reader users

Status: alpha. Added in Components 2.16.0.

A drag-to-reorder wrapper for lists and rows, with a Move menu on each handle
for keyboard and screen reader users.

## Import

```tsx
import { Sortable } from '@oztix/roadie-components/sortable'
```

## Examples

### Default

Wrap a `List` in `Sortable` and give each `List.Item` a `value`. The row takes
a drag handle in front. Pass the values in order as `items`, and store the new
order in `onReorder`. Drag a handle to move its row, or click it to open the
Move menu. The drag preview is the browser's own snapshot of the row.

```tsx
function Columns() {
  const names = {
    date: 'Date',
    event: 'Event',
    sku: 'SKU',
    price: 'Price',
    sold: 'Sold'
  }
  const [order, setOrder] = useState(Object.keys(names))
  return (
    <Sortable items={order} onReorder={setOrder} label='columns'>
      <List>
        {order.map((value) => (
          <List.Item key={value} value={value} title={names[value]} />
        ))}
      </List>
    </Sortable>
  )
}

render(<Columns />)
```

### Variants

Set `orientation='horizontal'` for items in a row. Drops land on the left or
right edge, and the menu moves items left and right, or to the start and end.

```tsx
function Stages() {
  const [order, setOrder] = useState(['Doors', 'Support', 'Headliner', 'Encore'])
  return (
    <Sortable items={order} onReorder={setOrder} orientation='horizontal' label='running order'>
      <div className='flex flex-wrap gap-2'>
        {order.map((value) => (
          <Sortable.Item
            key={value}
            value={value}
            label={value}
            className='flex h-10 shrink-0 items-center gap-1 rounded-full emphasis-subtle pe-4 ps-1'
          >
            <Sortable.Handle size='xs' />
            <span className='font-semibold text-strong'>{value}</span>
          </Sortable.Item>
        ))}
      </div>
    </Sortable>
  )
}

render(<Stages />)
```

### States

`disabled` on `Sortable` turns off dragging and the Move menu for every item.
`disabled` on one `Sortable.Item` stops that item being dragged or moved from
its own menu; other items can still move past it. A `List.Item` has no
per-row `disabled`, so build a row you need to hold back with `Sortable.Item`.

```tsx
function Columns() {
  const [order, setOrder] = useState(['Event', 'Date', 'SKU'])
  return (
    <div className='grid gap-4'>
      <div className='grid gap-2'>
        <p className='text-sm text-subtle'>Disabled</p>
        <Sortable items={['Date', 'Event']} onReorder={() => {}} disabled>
          <List>
            <List.Item value='Date' title='Date' />
            <List.Item value='Event' title='Event' />
          </List>
        </Sortable>
      </div>
      <div className='grid gap-2'>
        <p className='text-sm text-subtle'>One item disabled</p>
        <Sortable items={order} onReorder={setOrder} label='columns'>
          <ul className='grid gap-2'>
            {order.map((value) => (
              <Sortable.Item
                key={value}
                value={value}
                label={value}
                disabled={value === 'Event'}
                render={<li />}
                className='flex items-center gap-3 rounded-xl emphasis-normal p-2 data-disabled:text-subtle'
              >
                <Sortable.Handle />
                <span className='font-semibold'>{value}</span>
              </Sortable.Item>
            ))}
          </ul>
        </Sortable>
      </div>
    </div>
  )
}

render(<Columns />)
```

### Composition

`Sortable.Item` renders a `<div>` by default. Pass `render` to swap the
element, such as `<li />` inside your own list, and put a `Sortable.Handle`
anywhere inside it. Each item sets `data-dragging` while it is dragged and
`data-drop-edge` (`top`, `bottom`, `left` or `right`) while it is the drop
target, for your own styling; the dimmed item and the accent drop line are
built in. Give each item a `label` so the handle and menu can name
it: "Reorder Sydney", "Move Sydney up".

```tsx
function Cities() {
  const [order, setOrder] = useState(['Brisbane', 'Sydney', 'Melbourne', 'Perth'])
  return (
    <Sortable items={order} onReorder={setOrder} label='tour stops'>
      <ol className='grid gap-2'>
        {order.map((value) => (
          <Sortable.Item
            key={value}
            value={value}
            label={value}
            render={<li />}
            className='flex items-center gap-3 rounded-xl emphasis-normal p-2'
          >
            <Sortable.Handle />
            <span className='font-semibold text-strong'>{value}</span>
          </Sortable.Item>
        ))}
      </ol>
    </Sortable>
  )
}

render(<Cities />)
```

### With a popover

Drag and drop works inside a modal `Popover`. A long list scrolls by itself
while you drag near its top or bottom edge.

```tsx
function ColumnSettings() {
  const [order, setOrder] = useState(
    Array.from({ length: 12 }, (_, index) => `Column ${index + 1}`)
  )
  return (
    <Popover modal>
      <Popover.Trigger render={<Button>Columns</Button>} />
      <Popover.Content>
        <Popover.Body className='max-h-72 overflow-y-auto'>
          <Sortable items={order} onReorder={setOrder} label='columns'>
            <List>
              {order.map((value) => (
                <List.Item key={value} value={value} title={value} />
              ))}
            </List>
          </Sortable>
        </Popover.Body>
      </Popover.Content>
    </Popover>
  )
}

render(<ColumnSettings />)
```

## Guidelines

### Wrap the list, keyed by value

**Do**

```tsx
<Sortable items={order} onReorder={setOrder} label='columns'>
  <List>
    {order.map((value) => (
      <List.Item key={value} value={value} title={names[value]} />
    ))}
  </List>
</Sortable>
```

Put `Sortable` around the `List` and key each row by its value, so focus returns to the handle of the row that moved.

**Don’t**

```tsx
<List>
  <Sortable items={order} onReorder={setOrder}>
    {order.map((value, index) => (
      <List.Item key={index} value={value} title={value} />
    ))}
  </Sortable>
</List>
```

Don't put it inside the list or key rows by index. Sortable adds a hidden live region after its children, which can't sit inside a list, and index keys send focus to the wrong handle.

### Name the items and the collection

**Do**

```tsx
<Sortable label='columns' …>
  <Sortable.Item value='sku' label='SKU'>…</Sortable.Item>
</Sortable>
```

Give each item a `label` (a text `List.Item` title does this for you) and the root a `label`. The handle reads "Reorder SKU" and moves are announced as "SKU moved to position 3 of 7 columns".

**Don’t**

```tsx
<Sortable.Item value='sku'>
  <SkuIcon />
</Sortable.Item>
```

Don't leave an item unnamed. Every handle then reads "Reorder", and a screen reader user can't tell the rows apart.

### Keep rows static and drag from the handle

**Do**

```tsx
<List.Item value='sku' title='SKU' trailing={<Toggle … />} />
```

Put actions in the row's `trailing` slot as buttons or toggles. A reorderable row is not a link or button, and ignores `href`, `onClick`, `current` and `chevron`.

**Don’t**

```tsx
<List.Item value='sku' title={<input … />} />
```

Don't put text fields in a sortable item. The whole item is draggable, so its text can't be selected, and Firefox can't place the caret in a field inside it.

## Accessibility

- **Keyboard and screen readers**: there is no keyboard dragging. The handle is
  a menu button. Enter or Space opens the Move menu, and moves past the ends
  are disabled. After a move, focus returns to the moved item's handle.
- **Announcements**: every move, by menu or by drag, is announced in a polite
  live region: "SKU moved to position 3 of 7 columns".
- **Pointer**: a press on the handle that turns into a drag never opens the
  menu. A click without a drag does.
- **Motion**: the dragged item fades without a transition when reduced motion
  is on. In forced colours the drop line uses the system highlight colour.

## API reference

### Sortable

```tsx
import { Sortable } from '@oztix/roadie-components/sortable'
```

- `items`: `readonly string[]`. Required. Each item's `value`, in order.
- `onReorder`: `(next: string[], move: SortableMove) => void`. Required. Called with the new order after a drag or a Move menu choice.
- `orientation`: `"horizontal" | "vertical"`. Defaults to `'vertical'`. The direction items run in, which sets the drop edges and menu wording.
- `disabled`: `boolean`. Defaults to `false`. Turns off dragging and the Move menu.
- `label`: `string`. Names the collection in announcements, as in "SKU moved to position 3 of 7 columns".

### Sortable.Handle

Drags its item, and opens a Move menu on click or Enter for anyone not dragging with a pointer.

- `className`: `string | ((state: ButtonState) => string)`. CSS class applied to the element, or a function that returns a class based on the component's state.
- `intent`: `"neutral" | "brand" | "brand-secondary" | "brand-blue" | "brand-orange" | "brand-purple" | "accent" | "danger" | "success" | "warning" | "info" | null`.
- `emphasis`: `"strong" | "normal" | "subtle" | "subtler" | null`. Defaults to `subtler`.
- `size`: `"xs" | "sm" | "md" | "lg" | "icon-xs" | "icon-sm" | "icon-md" | "icon-lg"`. Defaults to `sm`. Icon-button sizing. Use `'xs' | 'sm' | 'md' | 'lg'` (plain) — the `'icon-*'` aliases are accepted for backwards compatibility but discouraged.
- `focusableWhenDisabled`: `boolean`. Defaults to `false`. Whether the button should be focusable when disabled.
- `nativeButton`: `boolean`. Defaults to `true`. Whether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if the rendered element is not a button (for example, `<div>`).
- `aria-label`: `string`. Defaults to `"Reorder {label}" from the item's `label\`\`.

### Sortable.Item

One reorderable item. Sets `data-dragging` and `data-drop-edge` for styling.

- `value`: `string`. Required. Identifies the item; one of the root's `items`.
- `label`: `string`. The item's name in the Move menu and announcements, as in "Move SKU down".
- `disabled`: `boolean`. Defaults to `false`. Stops this item being dragged or moved from its own Move menu. Other items can still move past it.
- `render`: `RoadieRenderProp`. Swaps the default `<div>`, e.g. `<li />` inside a list.

Docs updated 9 Oct 2026.
