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>
)
}Wiring the handles
Section titled “Wiring the handles”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.
Reordering from a toolbar or menu
Section titled “Reordering from a toolbar or menu”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.
Resetting to declaration order
Section titled “Resetting to declaration order”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.
Sticky and group constraints
Section titled “Sticky and group constraints”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 withDraggableGroupHeader, 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.
Saving the user’s order
Section titled “Saving the user’s order”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
fieldinvalidates 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.
Building your own drag UI
Section titled “Building your own drag UI”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’sdragstart/dragend.setColumnDropTarget$— what the drop zone publishes fromdragover.
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.