Documentation
Concepts

Column reordering

Columns and whole groups a person moves by dragging their header cell or with the keys, among their siblings, the order kept by the grid or by you.

The grid keeps the order, drags the header cell, takes the keys, holds every move to the rules and tells where a drop would land. The look is yours: the drop indicator, the dragged cell, the cursor, and any words a screen reader hears. The grid draws and says nothing.

Column reordering
Gallery

Reorderable columns

A column or a group with reorderable: true can be moved; the others stay where they are declared. A group moves whole, and its columns move inside it only by their own flag.

columns.tsx
const columns: ColumnOrGroup<Person>[] = [
    { key: "id", name: "#", width: 64 }, // fixed: never dragged
    { key: "name", name: "Name", width: 180, reorderable: true },
    {
        key: "contact",
        name: "Contact",
        reorderable: true, // the group moves whole
        children: [
            { key: "email", name: "Email", width: 260, reorderable: true },
            { key: "city", name: "City", width: 140, reorderable: true },
        ],
    },
];

The order

The grid keeps columnOrder: keys of columns and groups, the order siblings take. Each list of siblings (a group's children, or the top level) is ordered on its own: the entries the order lists take the places of the listed ones, in its order, and the others keep their place. An empty order is the order of columns. A key that is no column or group is kept, so a column that comes back takes its place again. Like the widths, it is controlled or not:

  • columnOrder with onColumnOrderChange: a move is asked for, and only the prop applies it;
  • defaultColumnOrder: the grid starts from it, applies moves and tells onColumnOrderChange.
people.tsx
const [order, setOrder] = useState<ColumnOrder>([]);

<button onClick={() => setOrder([])}>Reset order</button>
<DataGrid.Root
    columns={columns}
    rows={people}
    columnOrder={order}
    onColumnOrderChange={setOrder}
>
    {/* … */}
</DataGrid.Root>

columns stays as you declared it. The grid lays its columns, its header, its windows and its aria-colindex out in the order, and what is keyed already follows the columns: the widths, the sort, the selection and the expanded rows. The active cell follows its column (a header cell included), so a move never changes the cell a person is on. Controlled, an active position the order moved stays where its column went, and onActivePositionChange tells it once. Saving the order (to storage, to a server) is yours: it is a plain array.

The rules

  • Among siblings only. A column moves among the columns of its group, a group among the entries beside it. Moving a column into another group is not a move.
  • Groups whole. A group's header cell moves the group and every column under it.
  • Pinned with pinned. A pinned column lands among the ones pinned where it is (at the start, or at the end), the others among the others: pinned columns always lead and trail, whatever the order says. What counts is where it lands: a pinned column before the first column that scrolls becomes the last pinned one, a column after the last pinned one becomes the first that scrolls, and the same at the end.
  • Fixed entries. An entry without reorderable is never dragged nor moved by the keys, but its siblings may land on either side of it.

A move outside the rules is refused, and a middleware can refuse or rewrite any move. To keep the fixed # first, for instance:

first-column.ts
import { veto } from "@fragiola/data-grid";

model.use((ctx, next) =>
    ctx.command === "column-order.move" &&
    ctx.payload.targetKey === "id" &&
    ctx.payload.side === "before"
        ? veto("# stays first")
        : next(),
);

Dragging

A press with the primary button on a reorderable header cell reaches the grid after your own onPointerDown (so preventDefault there keeps it from dragging). The grid does not prevent it: until the pointer moves past a click's few pixels, it is a click, which focuses the cell and sorts a sortable column. Past them, the cell drags, holding the pointer. A press on a control inside the cell, or on a resize handle, never drags, and the click that ends a drag never sorts.

While the cell drags, at most once a frame, the grid works out where it would land: beside the sibling under the pointer, before or after it by the side of its middle. It reads the column positions, not the elements, so a sibling scrolled out of the rendered columns counts, under scroll scaling too. Near either edge of the columns that scroll (between the pinned parts), the grid scrolls them, faster nearer the edge, so a far column comes into reach; it stops once the siblings the column moves among all show on that side, so a drag inside a small group never scrolls the grid away. A pinned column's drag stays over its pinned part and scrolls nothing. Right to left, the sides mirror: "before" is to the right. A scroll during the drag (the wheel, the scrollbar) works the target out again from where the pointer is, so the release lands where the indicator shows.

The columns do not move during the drag: only the state changes. The release runs one column-order.move, one question to a controlled parent. Escape, a cancelled pointer or a lost capture end the drag and move nothing. Escape reaches the grid after your handlers, wherever focus is: one you prevent keeps the drag going.

On a touch screen, a drag on a header cell pans the grid unless the cell has touch-action: none, which is yours to set (and then the header no longer pans).

The indicator

The grid tells where a drop would land and leaves the drawing to you:

  • the dragged header cell carries data-dragging;
  • the sibling it would land beside carries data-drop-target, before or after;
  • a header cell that can be moved carries data-reorderable.

While a release would leave the entry where it is, no cell is a drop target. A line inside the target, on its side, reads as a slot; the dragged cell dimmed reads as the one in hand. The cursor is yours too.

grid.css
[data-grid-part="header-cell"][data-reorderable] {
    cursor: grab;
    user-select: none;
}

[data-grid-part="header-cell"][data-dragging] {
    cursor: grabbing;
    opacity: 0.5;
}

[data-drop-target="before"] {
    box-shadow: inset 3px 0 0 var(--accent);
}

[data-drop-target="after"] {
    box-shadow: inset -3px 0 0 var(--accent);
}

For a guide line or a ghost of your own, engine.get("column-reorder") is { columnKey, targetKey, side } during a drag (targetKey and side both null while a release would move nothing), else null, and the engine's column-reorder event tells each change. useHeaderCell reports reorderable, dragging and dropTarget, as does the state a header cell's className and style functions receive.

Keys

On a reorderable header cell in navigation, Ctrl+Shift+← and Ctrl+Shift+→ (⌘ on a Mac) move its column or group before the previous sibling or after the next one. The active cell follows it, and focus stays on it. At an end, or next to the pinned columns, nothing moves, and the page does not get the keys either. They run after your handlers, so preventDefault cancels them, and each is one column-order.move, which a middleware can refuse. On a body cell, or a header cell that does not move, they move the active cell as the arrows alone do. See Keyboard and accessibility.

Announcements

The grid announces nothing: what a screen reader hears after a move is your text, in a live region of yours. Tell it from what the grid shows, not by working the order out again: the model's header before and after the change (state.header.rows, the pinned columns first, as on screen) tells which column moved and where, in your columns' names.

people.tsx
const [order, setOrder] = useState<ColumnOrder>([]);
const [message, setMessage] = useState("");
const gridRef = useDataGridRef<Person>();
const moving = useRef(false);

useEffect(
    () =>
        gridRef.current?.model.subscribe(({ before, after }) => {
            if (!moving.current || after.header === before.header) return;
            moving.current = false;
            // "Moved Email after City.", from the header's rows and the columns' names
            setMessage(describeMove(before.header.rows, after.header.rows) ?? "");
        }),
    [gridRef],
);

<span role="status">{message}</span>
<DataGrid.Root
    columns={columns}
    rows={people}
    gridRef={gridRef}
    columnOrder={order}
    onColumnOrderChange={(next) => {
        moving.current = true;
        setOrder(next);
    }}
>
    {/* … */}
</DataGrid.Root>

The example writes describeMove in a few lines (announce.ts), and follows the root its ref holds (gridRef.subscribe). role="status" is a polite live region: it speaks once the person is done.

The commands

commanddoes
column-order.set { columnOrder }replaces the order (keys, each once)
column-order.move { columnKey, targetKey, side }moves a reorderable column or group before or after one of its siblings, within the rules; one landing where it is commits nothing
column-order.resetgives every column and group its declared place back
move.ts
model.run("column-order.move", { columnKey: "email", targetKey: "city", side: "after" });

A move naming a key that is no column or group fails (not_found); one that is not reorderable, not a sibling, or landing across the pinned columns' edge is refused (refused). Reads: get("column-order"), get("columns") (the columns in their order) and get("column-entries") (as declared). Reach them with useDataGrid() or a gridRef.

What it shows

onattributewhen
header celldata-reorderableits column or group can be moved
header celldata-dragginga drag is moving it
header celldata-drop-targetbefore or after: a drop would land on that side of it

Moving a column into another group, pinning by dragging, columns moving live during the drag and reordering rows are not part of it.