Writing CSSJavaScriptInterface
Auto-sizing resizable table columns
Resizable table columns that auto-fit their content until you drag one, then remember only what you changed. Plain HTML, CSS and JavaScript, no dependencies.
Say you have a lot of tables — different columns in each, columns the user can add and remove at will, and a requirement that any column edge can be dragged wider. There are a hundred snippets showing how to wire the drag in plain HTML, CSS and JavaScript. Almost none mention the part that actually stops you.
Here’s the finished thing. Drag any column edge; double-click one to hand it back to the auto-fit. Nothing below is needed to make it work — it’s all explaining why it takes this shape.
See the Pen Auto-sizing resizable table columns by AbJalilCs (@AbJalilCs) on CodePen.
The part that stops you
Dragging a column edge only works under table-layout: fixed. Under auto layout
the browser reads your content and decides for itself, so you can set a column to
240px and it’ll cheerfully ignore you. Fixed is the mode where the number you set
is the number you get — the whole basis of a drag.
But fixed also means the browser stops reading the content at all, so every column
now owes it a starting width. Fine for one table. Not fine when the columns come
out of a user’s saved view and you don’t know at build time what they even are —
and not fine either when writing width: 180px forty times means re-tuning it
every time a label changes.
Which leaves you between a layout mode that can’t be dragged and one that needs numbers you don’t have.
Auto layout is the measuring instrument
The browser is already very good at working out how wide a column should be — that’s what auto layout is. It just can’t be dragged. So use it, then leave.
The table starts in auto layout with no table-layout declared. Before anything
is painted, read the widths the browser worked out, write them onto the columns,
and only then switch to fixed. Auto does the sizing; fixed holds it still so it
can be dragged. That’s about six lines. The rest of this post is what goes wrong
once you try it on real tables.
The markup: widths on <col>, not <th>
Widths go on <col> elements in a <colgroup>, not on the <th>. A <th> only
covers the header, and if the header’s hidden or the column has no header you’ve
got nothing to hang the width on. A <col> is the column.
<div class="rt-frame">
<table class="rt">
<colgroup>
<col data-col="ref">
<col data-col="sample">
<col data-col="client">
<!-- Unsized on purpose. Explained below — this one matters. -->
<col>
</colgroup>
<thead>
<tr>
<th data-col="ref">
<div class="th-inner"><span class="th-label">Ref</span></div>
</th>
<!-- data-has-sort: one control the label can't shrink into. -->
<th data-col="sample" data-has-sort>
<div class="th-inner">
<span class="th-label">Sample name</span>
<button class="th-sort" aria-label="Sort"></button>
</div>
</th>
<!-- data-min: this one needs more room than the default floor. -->
<th data-col="client" data-min="90">
<div class="th-inner"><span class="th-label">Client</span></div>
</th>
<!-- Slack header: no data-col, so it is never measured. -->
<th></th>
</tr>
</thead>
<tbody><!-- … --></tbody>
</table>
</div>
Every resizable column carries a data-col on both the <col> and the header
cell, and that string is the column’s identity everywhere — in the measurement,
in the drag, in storage.
The CSS, and table-layout: fixed
/* The scroll viewport. A border or radius belongs here, not on the
table, so scrolling never clips it. */
.rt-frame { overflow-x: auto; }
/* No `table-layout` here on purpose — the table starts in auto. */
.rt { width: 100%; border-collapse: collapse; }
.rt th, .rt td { white-space: nowrap; }
.rt th { position: relative; } /* the grip anchors against the cell */
.th-inner { display: flex; align-items: center; gap: 6px; min-width: 0; }
.th-label { overflow: hidden; text-overflow: ellipsis; }
/* Where the measurement lands. */
.rt.is-sized {
table-layout: fixed;
width: 100%;
}
.rt.is-sized th,
.rt.is-sized td { overflow: hidden; text-overflow: ellipsis; }
/* Inside the cell, not astride the seam: .is-sized clips cell overflow,
so a straddling handle would be cut in half. The 9px box is the hit
area; the 1px rule inside it is all you see. */
.rt-grip {
position: absolute;
inset-block: 0;
right: 0;
width: 9px;
cursor: col-resize;
touch-action: none; /* or the browser takes the gesture for panning */
}
.rt-grip::before {
content: "";
position: absolute;
inset-block: 0;
right: 0;
width: 1px;
background: #e0e0e0;
}
.rt-grip:hover::before,
.rt-grip.is-dragging::before { background: #888; }
Nothing there is load-bearing except table-layout: fixed. The single most
important line in this whole post isn’t in that file at all — it’s the last <col>
in the markup above, and it gets its own section further down.
Measuring what each column wants
The obvious version is too simple: read the width the browser gave each column, write it down, switch to fixed. It falls apart on the second table, because the number you froze isn’t a good width — it’s whatever auto layout did with the spare room.
What it does with spare room is hand it out in proportion to how much content each column has. One long description column beside six short ones takes nearly all of it, and the six sit at barely more than their text width. That’s a split to replace, not to record, and it’s worst on a wide screen where there’s most surplus to misallocate.
So measure something else. Set table.style.width = 'max-content' and every
column reports what it needs to never ellipsize, with the frame no longer
squeezing it. Call it want — the only measurement the sizing needs. What happens
to the room left over is a decision, taken further down.
function measure() {
const ids = cols().map((c) => c.dataset.col);
// Before max-content: measureMin reads the header's own box, which a
// specified width would overwrite.
for (const id of ids) mins[id] = measureMin(id);
// At max-content: what each column needs to never ellipsize.
table.style.width = 'max-content';
const want = {};
let wanted = 0;
for (const id of ids) {
want[id] = Math.max(contentWidth(id), mins[id]);
wanted += want[id];
}
// Everything that isn't a measured column: borders, the slack cell's padding,
// any structural track.
const structural = table.offsetWidth - wanted;
table.style.width = '';
const frameW = frame.clientWidth;
const available = frameW > 0 ? frameW - structural : wanted;
const chosen = distribute(ids, want, available);
// …apply, then add the class
}
The measurement and the reset run in one task, so the max-content layout is
computed but never painted. Put an await or a requestAnimationFrame in the
middle and you’d put it on screen.
Note that available comes off the frame, not the table: under auto layout
width: 100% is a floor, not a ceiling, so an overflowing table has already grown
past its frame and its own width is not what you want to distribute into.
And contentWidth rounds up, then adds a pixel:
function contentWidth(id) {
const cell = head(id);
if (!cell) return 0;
return Math.ceil(cell.getBoundingClientRect().width) + 1;
}
A column set to exactly its content’s width still ellipsizes: the box is fractional, rounding throws the fraction away, and the column lands a hair under the overflow test it has to pass. That pixel is the difference between a table that looks measured and one where half the values are cut off for no reason.
Handing out the surplus
distribute is where the proportional-split problem gets fixed. If you only read
one function here, read this one.
const CAP = 320; // a STARTING width, not a ceiling
function distribute(ids, want, available) {
const total = (map) => ids.reduce((sum, id) => sum + map[id], 0);
// Cap everyone first.
const out = {};
for (const id of ids) {
out[id] = Math.min(want[id], Math.max(CAP, mins[id]));
}
let surplus = available - total(out);
if (surplus <= 0) return out;
// Hand the surplus back to the still-elided columns in EQUAL shares,
// re-splitting each round over whichever columns their share overshoots.
let hungry = ids.filter((id) => want[id] > out[id]);
while (hungry.length) {
const share = surplus / hungry.length;
const sated = hungry.filter((id) => want[id] - out[id] <= share);
if (!sated.length) {
for (const id of hungry) out[id] += share;
break;
}
for (const id of sated) {
surplus -= want[id] - out[id];
out[id] = want[id];
}
hungry = hungry.filter((id) => !sated.includes(id));
}
// Everything reached its content width and the frame is still wider: fill it,
// rather than leave the whole gap to the slack column.
const leftover = available - total(out);
if (leftover > 0) {
const share = leftover / ids.length;
for (const id of ids) out[id] += share;
}
return out;
}
The equal share is deliberate, and it’s the easiest thing here to get backwards. Sharing the surplus in proportion to how much each column still needs feels obviously right — give more to whoever’s short by more — and it reproduces the exact split the whole approach exists to replace, because the column with the most outstanding need is the widest one, the one already eating everything.
Equal shares invert that. Columns with modest content reach their full width and drop out of the pool, their leftovers go round again, and the genuinely long one takes what’s left and ellipsizes — which is the right column to ellipsize. The cap is an opening position, not a maximum, despite the name: nothing stays at 320 unless there’s no surplus to give back.
One rule covers both cases. Where the content doesn’t fit, the outstanding need
stays above the surplus and the loop ends on the even split; where it does, every
column reaches want and leftover spreads what’s over. It’s tempting to
special-case the roomy table and keep the browser’s own widths there — but that
hands the spare room straight back to the widest column, which is what this
function exists to stop.
Minimum widths
A column can’t be dragged down to nothing, so it needs a floor — and the floor isn’t one number. A header carrying a sort button needs more room before it stops making sense than a bare label does.
You can work that out by measuring: walk the header’s children, add up the ones that can’t shrink, read the flex gap off the computed style. That’s about fifteen lines, most of them subtle. The header is markup you control, though, so it’s far easier to just say what’s in it:
<th data-col="sample" data-has-sort> <!-- label + one control -->
<th data-col="client" data-min="90"> <!-- just needs more room -->
const MIN_COLUMN = 44; // enough of a label to still read
const CONTROL = 22; // one header control: a 16px button plus its 6px gap
function measureMin(id) {
const cell = head(id);
if (!cell) return MIN_COLUMN;
const floor = Number(cell.dataset.min) || MIN_COLUMN;
return floor + (cell.hasAttribute('data-has-sort') ? CONTROL : 0);
}
Two attributes, no DOM walking, no getComputedStyle. data-min raises the floor
for a column that needs it — a label that is itself a control, say, like a dropdown
that has to stay wide enough to read as one. Another kind of control is another
flag and another constant.
The trade is that CONTROL now has to agree with the CSS. Change the button to
20px and the floor is quietly 4px short — which the measured version couldn’t do,
and is the honest argument for keeping it.
The drift, and the empty column that fixes it
This is the bug that takes longest to find, and the one that makes the whole thing feel cheap until it’s fixed. You grab a column edge and drag, and the seam doesn’t stay under the pointer — it runs ahead of the cursor and slowly converges as you pull. Meanwhile every other column visibly shrinks while you drag just the one.
Two symptoms that look like two problems. They’re the same one, and the cause is that the width you set is not the width you get.
Under fixed layout the browser gives leftover space to columns with width: auto
first. Only when every column has a specified width does it fall back to scaling
them all proportionally so the table fills its width. So a table whose columns are
all specified, and which doesn’t quite fill its frame, gets quietly rescaled — and
a rescaled column is one whose edge is no longer where you put it.
A 520px frame, four columns, one set to 120 and the rest to 110:
no trailing <col>
set 110 120 110 110
used 127 139 127 127 ← everything scaled up to fill 520
drift on the dragged column: +19px
with a trailing unsized <col>
set 110 120 110 110
used 110 120 110 110 ← exact; the leftover 70px lands in the last col
drift on the dragged column: 0px
That’s the whole fix: one empty <col> at the end of the <colgroup>, with no
width, that nobody can see. It gives the surplus somewhere to go, so every other
column keeps exactly the number you set.
The drift also disappears on its own once the columns outgrow the frame — there’s no surplus left to redistribute — which is what makes it maddening to track down. It shows up on roomy tables and vanishes on crowded ones, so it reads as intermittent when it’s perfectly deterministic.
One thing worth saying, because it cost a day: the table’s own width has nothing
to do with this. width: 100% and width: 0 with min-width: 100% produce
byte-identical output in every case — with the empty column and without it. If
you’ve seen the width: 0 trick recommended for this, it isn’t what’s helping.
The drag itself
Small function, three things in it worth pointing at.
grip.addEventListener('pointerdown', (event) => {
const w = widths[id];
drag = { x: event.clientX, from: w, min: mins[id], width: w };
grip.setPointerCapture(event.pointerId);
grip.classList.add('is-dragging');
event.preventDefault();
event.stopPropagation();
});
grip.addEventListener('pointermove', (event) => {
if (!drag) return;
drag.width = Math.max(drag.min, drag.from + (event.clientX - drag.x));
setWidth(id, drag.width, false);
});
The delta is measured from where the drag started, never accumulated.
width += event.movementX is the obvious version and it breaks against the
minimum: once a column is clamped at its floor, every further pixel of leftward
movement is thrown away, so on the way back out it starts growing while the
pointer is still well left of where it left off.
setPointerCapture keeps events coming once the cursor outruns the 9px grip,
which it will immediately. And preventDefault is there because the header cell
is also a sort control — without it, every resize sorts the table too.
What gets saved, and what doesn’t
Widths go to localStorage keyed by data-col — but only for columns somebody
actually dragged.
const dragged = new Set();
function persist() {
if (!storageKey) return;
const out = {};
for (const id of dragged) if (widths[id] != null) out[id] = widths[id];
try {
localStorage.setItem(storageKey, JSON.stringify(out));
} catch {
// Full or blocked storage must not break the session.
}
}
Saving everything is the obvious choice and the wrong one. Write down every measured width on first load and the auto-fit runs exactly once per browser, after which the table is frozen against whatever data happened to be on screen that first time. Add a column, change a label, ship a font — none of it takes.
Storing only deliberate changes keeps the two behaviours apart: a column you dragged is a decision and it’s kept, a column you never touched re-measures on every mount. It self-cleans, too. Delete a column and its stored width simply stops being read, because the measure pass only walks the columns that exist now; the next write drops the stale key. There’s no migration to write.
Double-click on a grip hands a column back to the auto-fit:
function resetWidth(id) {
if (!dragged.delete(id)) return;
persist();
reseed(); // re-measure the whole table, not just this column
}
The whole table re-measures, not the one column. It has to: the auto widths are distributed against the frame, so one column’s fitted width is a function of every other column’s. Columns the user sized keep theirs, straight back out of storage.
Wait for the font
The sort of bug that only shows up on someone else’s machine.
function reseed() {
widths = {};
mins = {};
table.classList.remove('is-sized');
for (const c of cols()) c.style.width = '';
document.fonts.ready.then(measure);
}
Webfonts load with font-display: swap, so text renders in a fallback face until
the real one lands. Measure before that and you’ve measured the fallback — every
column slightly too generous or too tight, and on a fast connection you’d never
catch it. Since the result gets persisted, that error would be permanent.
Clearing the widths before the await matters just as much. Re-measure a table
that still has widths on its <col> elements and max-content reports back the
widths you already set, so it can never grow to fit new content.
Where this doesn’t go
It won’t do wrapping cells — the whole thing rests on white-space: nowrap, so a
cell that wraps to two lines makes max-content report a width that never appears
on screen. It doesn’t do column reordering either. And if the capped widths still
don’t fit, the table scrolls, which is intended rather than a fallback: seven
columns of long text in a 978px frame genuinely does not fit, and squeezing them
to illegibility is worse than a scrollbar.
The whole thing
It’s about 180 lines of JavaScript with no dependencies, and the whole thing is on CodePen if you’d rather read it in one piece than in the fragments above.
Most of it isn’t the resizing. The drag is an afternoon. The rest is what auto
layout does with spare room, and the fact that a table’s own width quietly
overrides the numbers you give its columns.