Deck
Browse the catalog

Build / Molecules

ReorderList

  • molecule
  • play-fn

A short list whose order is the point — the buttons on a card, the steps of a flow — reorderable by dragging or by keyboard.

Dragging is the discoverable way and it is not the only way. A grip you can only drag is a control that does not exist for anyone using a keyboard, and "drag to reorder" is the most common place a settings page quietly stops being operable. So the handle is a real button: focus it and Arrow Up or Arrow Down moves its row, one position per press, with the new position announced. Focus rides along with the row it belongs to, because the element is keyed by id and React moves the node rather than rebuilding it.

Every row can move. A row whose contents are decided elsewhere — a button whose label is set on another screen — is still a row you can put where you like; that is the caller's business to render read-only, not this list's business to pin.

Built for a handful of rows. Past ten or so, ordering wants a different instrument — a sort, or a number you type.

import { ReorderList } from '@meetcard/deck'

Example

<ReorderList label="Calls to action" items={ctas.map((c) => ({ id: c.id, label: c.label, content: <CtaFields {...c} /> }))} onReorder={(ids) => setOrder(ids)} />

Stories

Every preview below is the real component, rendered in this page from the same story Storybook runs. The width control resizes each preview's container; CSS media queries still follow the browser window, so breakpoint behaviour is worth checking in Storybook.

Three rows in the order they will appear on the card.

Default
Source
const Default = function Wired() {
  const [order, reorder] = useCtas()
  return (
    <div style={{ maxWidth: 520 }}>
      <ReorderList
        label="Calls to action"
        onReorder={reorder}
        items={order.map((cta) => ({
          id: cta.id,
          label: cta.label,
          content: (
            <Text as="span" size="sm">
              {cta.label}
            </Text>
          ),
        }))}
      />
    </div>
  )
};

The keyboard path, which is the one that has to work. Focus a handle and the arrow keys move its row — and focus rides along with it, so a second press moves the same row again rather than whatever took its place.

Moving With The Keyboard
Source
const MovingWithTheKeyboard = function Wired() {
  const [order, reorder] = useCtas()
  return (
    <div style={{ maxWidth: 520 }}>
      <ReorderList
        label="Calls to action"
        onReorder={reorder}
        items={order.map((cta) => ({
          id: cta.id,
          label: cta.label,
          content: (
            <Text as="span" size="sm">
              {cta.label}
            </Text>
          ),
        }))}
      />
    </div>
  )
};

Rows whose contents are set on another screen. They still move — where a button sits on your card is your decision even when what it says is not — so the read-only fields are the caller's business and the handle stays.

With Read Only Fields
Source
const WithReadOnlyFields = function ReadOnly() {
  const [order, reorder] = useCtas()
  return (
    <div style={{ maxWidth: 520 }}>
      <ReorderList
        label="Calls to action"
        onReorder={reorder}
        items={order.map((cta) => ({
          id: cta.id,
          label: cta.label,
          content: (
            <>
              <Input
                label={`${cta.label} label`}
                hideLabel
                size="sm"
                readOnly={cta.id === 'book'}
                defaultValue={cta.label}
              />
              <Input
                label={`${cta.label} link`}
                hideLabel
                size="sm"
                readOnly={cta.id === 'book'}
                defaultValue={cta.href}
              />
            </>
          ),
        }))}
      />
    </div>
  )
};

Props

PropTypeDefaultDescription
itemsrequiredReorderListItem[]——
labelrequiredstring—Accessible name for the list, e.g. "Calls to action".
onReorderrequired(ids: string[]) => void—Fires with the full order, top to bottom, after any move.

Also accepts everything from Omit, so id, data-* and event handlers pass straight through.

Open in Storybookfor controls, the a11y panel and the interaction runner.