Skip to content

Column Reordering

Column reordering lets the user move columns into a different position than the JSX declares. The order in JSX becomes the initial order; the user changes it through a drag UI mounted in the header, a toolbar action, or programmatic engine calls.

import { DataTable, DataTableCell, DataTableColumn, DataTableColumnHeader, HeaderOverlay, HeaderStart } from '@/components/ui/data-table'
import { ReorderDropZone, ReorderGrip } from '@/components/ui/data-table/column-reorder'
import { localModel } from '@virtuoso.dev/data-table'

const rows = Array.from({ length: 40 }, (_, index) => ({
  id: `SKU-${String(index + 1).padStart(3, '0')}`,
  name: `Product ${index + 1}`,
  category: ['Office', 'Peripherals', 'Audio'][index % 3]!,
  status: ['In stock', 'Low stock', 'Backorder'][index % 3]!,
  region: ['US', 'EU', 'APAC'][index % 3]!,
}))

const model = localModel({ data: rows })
const columns = [
  ['name', 'Product', 'font-medium'],
  ['category', 'Category', ''],
  ['status', 'Status', ''],
  ['region', 'Region', ''],
] as const

export default function App() {
  return (
    <DataTable className="rounded-xl" computeRowKey={({ data }) => data.id} model={model} style={{ height: 320 }}>
      {columns.map(([field, label, cellClassName]) => (
        <DataTableColumn key={field} field={field}>
          <DataTableColumnHeader>
            <HeaderStart component={ReorderGrip} />
            <HeaderOverlay component={ReorderDropZone} />
            {() => label}
          </DataTableColumnHeader>
          <DataTableCell className={cellClassName}>{({ cellValue }) => String(cellValue)}</DataTableCell>
        </DataTableColumn>
      ))}
    </DataTable>
  )
}

Two pieces mount inside each column header: a draggable grip the user can grab, and a drop zone that paints the indicator line when another column is dragged over. They live in two different header slots so they don’t fight each other for space — the grip sits in HeaderStart next to the title, and the drop indicator overlays the whole header through HeaderOverlay without disturbing layout.

import { HeaderOverlay, HeaderStart } from '@/components/ui/data-table'
import { ReorderDropZone, ReorderGrip } from '@/components/ui/data-table/column-reorder'
;<DataTableColumn field="status">
  <DataTableColumnHeader>
    <HeaderStart component={ReorderGrip} />
    <HeaderOverlay component={ReorderDropZone} />
    {() => 'Status'}
  </DataTableColumnHeader>
  <DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
</DataTableColumn>

ReorderGrip and ReorderDropZone are shadcn registry components — drop them into a column header and reordering works. They’re not the whole story, though: under them is an engine action any other surface can publish to.

Drag isn’t the only way to move columns. A “Move to first” menu item or an arrow-key shortcut can reach for reorderColumns$ from outside the header. The shape is the same as what the drag UI publishes: source, target, and a before / after position.

import { columns$, useEngineRef, useRemoteCellValue, useRemotePublisher } from '@virtuoso.dev/data-table'
import { reorderColumns$ } from '@virtuoso.dev/data-table/column-reorder'

const engineRef = useEngineRef()
const columns = useRemoteCellValue(columns$, engineRef)
const reorderColumns = useRemotePublisher(reorderColumns$, engineRef)

const entries = [...(columns ?? new Map())]
const statusKey = entries.find(([, column]) => column.field === 'status')?.[0]
const nameKey = entries.find(([, column]) => column.field === 'name')?.[0]

if (statusKey && nameKey) {
  reorderColumns({ sourceKey: statusKey, targetKey: nameKey, position: 'before' })
}

The two-step lookup is genuine API friction worth knowing: reorderColumns$ operates on runtime column keys (the identity the engine uses internally), not the field names you write in JSX. Most callers know the field — “move ‘status’ before ‘name’” — and translate by walking columns$ once. Column keys are stable for a given declaration, so for repeated programmatic moves it’s worth building the field → key map once at mount and stashing it in a ref.

For a “Reset to default order” button, publish resetColumnOrderToDeclaration$. The table already tracks the current component declaration order internally, so the caller doesn’t need to snapshot runtime column keys at mount.

import { useEngineRef, useRemotePublisher } from '@virtuoso.dev/data-table'
import { resetColumnOrderToDeclaration$ } from '@virtuoso.dev/data-table/column-reorder'

const engineRef = useEngineRef()
const resetColumnOrder = useRemotePublisher(resetColumnOrderToDeclaration$, engineRef)

;<button onClick={() => resetColumnOrder()} type="button">
  Reset order
</button>

If columnOrderPersistenceAdapter() is mounted, the reset is treated like any other order change and the declaration-order state is saved under the table’s persistence key.

Two rules narrow what the drag UI accepts, and they apply equally to engine-level reorders:

  • Sticky stays with sticky. A drag from a sticky="left" column only accepts drops on other left-sticky targets; right-sticky behaves the same. Non-sticky columns can’t be pulled into a pinned region by the drag UI, and the engine action treats sticky-ness as a column property rather than a positional one — moving a non-sticky column next to a pinned one doesn’t make it pinned.
  • Group drag is its own gesture. When columns live inside a ColumnGroup, the group header itself can be made draggable with DraggableGroupHeader, moving every member as one transaction. The single-column gesture stays available, but dragging an individual column across group boundaries breaks the visual band — see Column Groups → Reordering with groups for the full picture and the practical mitigations.

columnOrderPersistenceAdapter() plugs into State Persistence to keep the user’s order across reloads. The saved shape is a list of field names — stable, declarative identifiers — rather than the runtime keys the engine uses internally. As a result:

  • Adding a new column to the JSX places it at its declared position on next load. Saved state doesn’t list it, so the new column doesn’t get a stale slot somewhere arbitrary.
  • Removing a column drops it from saved state without disturbing the others.
  • Renaming a field invalidates that entry’s saved position — the saved state can’t find the column it was about. Field names are the persistence contract covers the rules around renames.

The shadcn grip and drop zone are a reference implementation. If your design system uses a different drag library, you want long-press for touch, or the drop indicator needs custom positioning, the underlying coordination primitives are public:

  • columnDragState$ — a reactive cell holding “what’s being dragged, where’s the drop indicator.” Grip writes, drop zone reads.
  • beginColumnDrag$ / endColumnDrag$ — lifecycle channels published from the grip’s dragstart / dragend.
  • setColumnDropTarget$ — what the drop zone publishes from dragover.

The source for ReorderGrip and ReorderDropZone in the shadcn registry is short and self-contained; copy it as a starting point and swap out the markup that doesn’t fit.

See the Column Reordering example for a runnable setup with persistence wired in.