Columns Reordering Implementation in Vanilla-Grid
This document explains how column reordering is implemented in Vanilla-Grid, including mouse drag-and-drop, touch hold-to-reorder, boundary normalization, validation rules, visual indicators, and localStorage persistence.
File location (May 2026): the reorder logic lives in
src/vanilla-grid/features/columns-reorder.feature.jsas a prototype extension ofVanillaGridColumnsFeature.
1. Feature Scope
Column reordering supports:
- Desktop HTML5 drag-and-drop on header cells
- Touch-based reorder via press-and-hold gesture
- Persisted column order across sessions (
localStorage) - Compatibility with selection column, sorting state, and frozen/unfrozen groups
The feature is controlled by options:
columns.reorderable(defaulttrue)columns.touchReorder(defaulttrue)columns.touchReorderHoldDelay(default220, minimum120)columns.reorderMarkerDeadZonePx(default20, minimum0)
2. Initialization and Event Wiring
In constructor initialization:
- Reorder options are normalized and stored.
- Internal state is initialized:
_dragColumnFromIndex,_dragColumnOverIndex,_dragColumnBeforeTarget_touchReorderPointerId,_touchReorderCandidate,_touchReorderActive,_touchReorderHoldTimer
Listener wiring (delegated):
renderHeader()attaches no per-cell reorder listeners.dragstart,dragend, andpointerdown(touch path) are registered once per grid onthis.headerby_initHeaderDelegatedListeners()(rendering.feature.js); the target column resolves frome.target.closest('th').dataset.colIndex, stamped on every cell. The delegated handlers re-checkreorderableColumnsand skip the selection checkbox column per event.dragoveranddropare not header-delegated at all: the drag-scoped document listeners_onDocDragOver/_onDocDrop(attached ondragstart, removed ondragend) are the single drop-target logic — the former per-cell_onHeaderDragOver/_onHeaderDropduplicated the same dead-zone / indicator /preventDefaultlogic and were deleted.
Document-level listeners are attached only during active drag phases:
- Desktop:
dragover,drop - Touch:
pointermove,pointerup,pointercancel
All are removed on end/cancel and in destroy() (which also removes the
delegated header listeners).
3. Desktop Reorder Flow (Mouse)
3.1 Drag start
_onHeaderDragStart(e, columnIndex):
- Guard conditions:
reorderableColumnsmust be true- Source column must exist
- Drag must not start from
.vn-grid-col-resizer
- Initializes drag state:
_dragColumnFromIndex = columnIndex_dragColumnOverIndex = columnIndex_dragColumnBeforeTarget = true
- Applies visual state:
- clears old indicators
- marks source header with
.vn-grid-col-dragging - creates drag ghost (
.vn-grid-col-drag-ghost)
- Configures
dataTransferand drag image - Attaches document-level drag listeners
3.2 Drag over
_onDocDragOver(e) (document-level, active only during a drag):
- Resolves the target column via
_resolveDocDragTarget(e)— fast pathe.target.closest('th')+ stampedcolIndex(no rect walk); fallback for pointers outside the cells but within a ±30 px vertical margin of the header:_findColumnIndexFromClientXrect hit-test - Computes
dropBeforeusing_computeDropBeforeWithDeadZone(rect, clientX) - Special-cases selection column (
__vgSelection__): force drop-after - Canonicalizes target via
_normalizeDropTarget(columnIndex, dropBefore) - Validates move with
_wouldReorderColumn(from, to, dropBefore) - If valid:
e.preventDefault()(enables drop)- sets
dropEffect='move' - updates indicator classes with
_setHeaderDropIndicator(...)
- Marks
_didColumnDragwhen hovering a column other than the source (so the click that follows dragend does not also sort)
3.3 Drop
_onDocDrop(e) (document-level, active only during a drag):
- Resolves the target with the same
_resolveDocDragTarget(e)fast path - Recomputes and normalizes target; validates with
_wouldReorderColumn e.preventDefault()+stopPropagation(), then executesreorderColumn(fromIndex, toIndex, { dropBefore })- If frozen columns exist, reapplies frozen grouping and re-renders
3.4 Drag end
_onHeaderDragEnd():
- Detaches document drag listeners
- Clears header drag/drop classes
- Resets drag indices
- Removes drag ghost element
4. Touch Reorder Flow
Touch reorder is initiated from _onHeaderPointerDown when:
pointerType === 'touch'reorderableColumns === truetouchReorder === true- press target is not a resize handle
4.1 Hold activation
On touch pointerdown:
- Candidate data is stored (
fromIndex, start coordinates, pointerId) - Document pointer listeners are attached
- A hold timer is started for
touchReorderHoldDelay
If hold timer completes, _activateTouchReorder():
- sets
_touchReorderActive = true - applies
.vn-grid-col-dragging - computes and shows valid drop indicator
- marks
_didColumnDrag = true
4.2 Move handling
_onTouchReorderPointerMove:
- Before activation: movement > 8px cancels reorder (interpreted as normal scroll/pan)
- After activation:
preventDefault()to avoid scroll interference- resolves target header cell under pointer with
_findHeaderCellIndexAtPoint - updates candidate
overIndex/dropBefore - validates with
_wouldReorderColumn - shows/clears indicators accordingly
4.3 Release and cancel
_onTouchReorderPointerUpcommits reorder if active and valid_cancelTouchReordercancels hold/active flow_finishTouchReordercentralizes cleanup (state reset + listener detach + class clear)
5. Drop Target Computation
5.1 Edge-strip dead zone
_computeDropBeforeWithDeadZone(rect, clientX) implements edge-based intent detection:
- Left edge strip →
dropBefore = true - Right edge strip →
dropBefore = false - Center zone →
null(no drop indicator)
Edge strip width:
edgeStrip = min(reorderMarkerDeadZonePx, rect.width * 0.40)
This prevents noisy flips near the center while keeping narrow columns usable.
5.2 Canonical boundary normalization
_normalizeDropTarget(index, dropBefore) converts equivalent boundaries into one canonical representation:
- Most "drop before X" positions become "drop after X-1"
- Exception: first column in dragged group preserves true drop-before
- Exception: avoids normalizing onto selection checkbox column
This prevents duplicate marker positions for the same insertion boundary.
6. Validation Rules (_wouldReorderColumn)
A reorder is allowed only when all conditions pass:
fromIndexandtoIndexare valid- Target is not selection checkbox column (
__vgSelection__) - Frozen/unfrozen parity matches:
- frozen columns can only be dropped in frozen group
- unfrozen columns can only be dropped in unfrozen group
dropBeforeis only allowed on the first column of the dragged group- Computed
insertIndexdiffers fromfromIndex
The final insertion index is computed as:
insertIndex = toIndex + (dropBefore ? 0 : 1)
if (fromIndex < insertIndex) insertIndex -= 1
7. Data Mutation and State Preservation (reorderColumn)
reorderColumn(fromIndex, toIndex, { dropBefore }) performs:
- Input validation
- Snapshot of current columns (
oldColumns) - Snapshot sort mapping by column key (
sortByKey) - In-place array move (
spliceremove + insert) - Rebuild
sortColumnsStateby keys against new order - Recompute legacy
sortState refreshHeaderLayout()persistColumnOrderToStorage()renderVisibleRows(true)if pool/data is already present
This ensures sorting remains semantically attached to column keys after reorder.
8. Persistence Model
Column order persistence uses localStorage.
8.1 Storage key resolution
resolveColumnOrderStorageKey():
vanilla-grid:column-order:{pathname}:{scope}
Where scope is first available of:
gridIdoption (auto-populated from the<vanilla-grid>host elementid)bodyTable.idviewport.idheaderTable.id'default'
Custom override is supported via persistence.columnOrder.storageKey option.
8.2 Save
persistColumnOrderToStorage() stores an ordered key array:
['name', 'city', 'weight', ...]
Also caches it in savedColumnOrderKeys.
8.3 Load and apply
loadColumnOrderFromStorage()parses and sanitizes saved keysapplySavedColumnOrder()rebuildsthis.columnsby key map- Selection column (
__vgSelection__) is always pinned to index 0 if present - Unknown/missing keys are ignored; remaining columns keep stable order
Saved order is applied in setColumns() before freeze regrouping and header refresh.
9. Visual Feedback and CSS Hooks
Base CSS classes used during reorder:
.vn-grid-col-dragging— dims source header while dragging.vn-grid-drop-before/.vn-grid-drop-after— insertion markers.vn-grid-col-drag-ghost— lightweight drag image
Marker pseudo-element visuals are configurable via theme variables on .vn-grid-header-table th:
--vn-grid-drop-tip-width--vn-grid-drop-tip-height--vn-grid-drop-tip-color--vn-grid-drop-tip-edge-inset--vn-grid-drop-tip-y-adjust
This keeps behavior in core JS while allowing theme-level visual tuning.
10. Interactions with Other Features
10.1 Selection column
- Not draggable as source/target for reordering
- Normalization and validation avoid placing boundaries on it
- Always pinned at beginning when applying saved order
10.2 Frozen columns
- Cross-boundary moves are blocked
- After successful drop, frozen grouping is re-applied to preserve contiguous frozen block
10.3 Sorting
- Sort chain is remapped by column keys after reorder
- Prevents sort state corruption when indices change
10.4 Header resize
- Reorder start is blocked when pointerdown target is
.vn-grid-col-resizer - Prevents accidental drag when user intends to resize
11. Failure-Safe and Cleanup Behavior
- Invalid targets always clear indicators and do not mutate state.
- Cancelled touch gestures remove timers/listeners and reset drag classes.
destroy()detaches document-level reorder listeners and touch listeners.- LocalStorage operations are wrapped in
try/catchwith warnings, so persistence failure never breaks UI behavior.
12. Example Configuration
const grid = new VanillaGrid({
columns: {
reorderable: true,
touchReorder: true,
touchReorderHoldDelay: 220,
reorderMarkerDeadZonePx: 20
},
persistence: {
columnOrder: { enabled: true, storageKey: 'my-grid:column-order' }
}
});
For strict session-only behavior (no persistence):
persistence: { columnOrder: { enabled: false } }