Skip to main content

Implementing Draggable Column Resizing in LWC Custom Tables

The standard lightning-datatable component does not support user-driven column resizing. When you need this capability, a custom HTML table with mouse event handlers gives you full control.

The Resize Handle

Add a draggable handle element inside each <th>. This narrow element sits on the right edge of the header cell and serves as the drag target.

<!-- customTable.html -->
<template>
<div class="table-container">
<table>
<thead>
<tr>
<template for:each={columns} for:item="col">
<th key={col.fieldName} style={col.style}>
<span>{col.label}</span>
<div
class="resize-handle"
data-field={col.fieldName}
onmousedown={handleResizeStart}
></div>
</th>
</template>
</tr>
</thead>
<tbody>
<!-- table rows -->
</tbody>
</table>
</div>
</template>

CSS for the Handle and Drag State

The resize handle is positioned absolutely within the header cell. During a drag operation, user-select: none on the body prevents text highlighting that would otherwise interfere with the drag gesture.

/* customTable.css */
th {
position: relative;
min-width: 60px;
}

.resize-handle {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 5px;
cursor: col-resize;
background: transparent;
}

.resize-handle:hover {
background: #0070d2;
}

.resizing {
user-select: none;
-webkit-user-select: none;
cursor: col-resize;
}

JavaScript: Mouse Event Handlers

The resize logic tracks three values: the column being resized, its starting width, and the mouse's starting X position. On mousemove, the delta between the current and starting X is applied to the column width.

// customTable.js
import { LightningElement, track } from "lwc";

export default class CustomTable extends LightningElement {
@track columns = [
{ fieldName: "name", label: "Name", width: 200 },
{ fieldName: "amount", label: "Amount", width: 150 },
{ fieldName: "status", label: "Status", width: 120 },
];

_resizeState = null;
_boundMouseMove;
_boundMouseUp;

connectedCallback() {
this._boundMouseMove = this.handleResizeMove.bind(this);
this._boundMouseUp = this.handleResizeEnd.bind(this);
}

disconnectedCallback() {
document.removeEventListener("mousemove", this._boundMouseMove);
document.removeEventListener("mouseup", this._boundMouseUp);
document.body.classList.remove("resizing");
}

get columnsWithStyle() {
return this.columns.map((col) => ({
...col,
style: `width: ${col.width}px;`,
}));
}

handleResizeStart(event) {
event.preventDefault();
const fieldName = event.target.dataset.field;
const column = this.columns.find((c) => c.fieldName === fieldName);

this._resizeState = {
fieldName,
startX: event.clientX,
startWidth: column.width,
};

document.addEventListener("mousemove", this._boundMouseMove);
document.addEventListener("mouseup", this._boundMouseUp);
document.body.classList.add("resizing");
}

handleResizeMove(event) {
if (!this._resizeState) return;

const delta = event.clientX - this._resizeState.startX;
const newWidth = Math.max(60, this._resizeState.startWidth + delta);

this.columns = this.columns.map((col) =>
col.fieldName === this._resizeState.fieldName
? { ...col, width: newWidth }
: { ...col }
);
}

handleResizeEnd() {
document.removeEventListener("mousemove", this._boundMouseMove);
document.removeEventListener("mouseup", this._boundMouseUp);
document.body.classList.remove("resizing");
this._resizeState = null;
}
}

Key Implementation Details

Bind to document, not the component. Mouse events fire on document so that the drag continues even if the cursor moves outside the table boundary. Without this, releasing the mouse outside the component would leave the resize stuck.

Clean up in disconnectedCallback. Always remove document-level event listeners when the component is destroyed. Failing to do so causes memory leaks and ghost event handlers.

Enforce a minimum width. The Math.max(60, ...) call prevents columns from collapsing to zero. Adjust the minimum to suit your content.

Immutable updates for reactivity. The columns array is replaced with a new array on every move event. Mutating the existing object in place would not trigger LWC's reactive rendering.

Persisting Column Widths

To preserve user preferences across sessions, serialize the column widths to localStorage on mouseup and restore them in connectedCallback:

handleResizeEnd() {
// ... cleanup code ...
const widths = {};
this.columns.forEach((col) => (widths[col.fieldName] = col.width));
localStorage.setItem("tableColumnWidths", JSON.stringify(widths));
this._resizeState = null;
}

connectedCallback() {
this._boundMouseMove = this.handleResizeMove.bind(this);
this._boundMouseUp = this.handleResizeEnd.bind(this);

const saved = localStorage.getItem("tableColumnWidths");
if (saved) {
const widths = JSON.parse(saved);
this.columns = this.columns.map((col) => ({
...col,
width: widths[col.fieldName] || col.width,
}));
}
}

This pattern delivers a polished, desktop-grade table interaction entirely within the LWC framework, with no third-party dependencies.