GODRICH

9 Data Table UI Patterns — Drag, Resize, Select

A data table ui is a grid of rows and columns you can reorder, narrow, and pull values out of by hand.

Auto-plays · click a tile to jump to its section · all nine in one zip

The order is not the order you build a table in. It is the order a reader gets stuck in. There are too many rows, so you change the order first (01). A title is cut off, so you widen its column (02). The column you keep checking is on the far right, so you drag it left (03). It still runs off the side, so you fold away the columns you never read (04). Those four are all about reshaping the table. The next two are about getting values out of it: you fence off the cells you want and take them (05), then you cut the row count down with conditions (06). The last three are the table talking back. Big and small numbers show it in color before you read them (07), the total stays on the floor however far you scroll (08), and the cells can be walked without a mouse (09).

01Priority sort that holds two columns at once

When two rows share a score, one column cannot decide the order. Shift-clicking a second header adds it as a tiebreaker and stamps a 1 and a 2 onto the two headers, so you can see which one is read first. The comparator walks the key list from the front and returns on the first key where the two values differ.

aria-sorttranslateYrotate
function msSort(key, add) {
  var was = {};
  [].forEach.call(msBody.children, function (el) { was[el.dataset.id] = el.getBoundingClientRect().top; });
  var hit = null;
  msKeys.forEach(function (k) { if (k.k === key) hit = k; });
  if (hit) hit.dir = -hit.dir;
  else msKeys = add ? msKeys.concat([{ k: key, dir: 1 }]).slice(-2) : [{ k: key, dir: -1 }];
  MS.sort(function (a, b) {
    for (var i = 0; i < msKeys.length; i++) {
      var k = msKeys[i];
      if (a[k.k] < b[k.k]) return -k.dir;
      if (a[k.k] > b[k.k]) return k.dir;
    }
    return 0;
  });
  msRender(); msMark();
}

02Column width you drag from the header edge

A column width has to live in exactly one place. This one keeps it in a single length-typed custom property that every row reads, and the guide line that follows your pointer reads the very same value. Write the width twice and the line and the edge will drift apart.

grid-template-columnspointermovetranslateX
@property --w1 {
  syntax: "<length>";
  inherits: true;
  initial-value: 132px;
}
.cr__row {
  display: grid;
  grid-template-columns: var(--w1) 12px 1fr;
}
@keyframes cr-w {
  0%   { --w1: 132px; }
  8%   { --w1: 132px; }
  34%  { --w1: 180px; }
  46%  { --w1: 180px; }
  72%  { --w1: 100px; }
  86%  { --w1: 132px; }
  100% { --w1: 132px; }
}

03Column reorder that lifts the header

Build the table out of rows and moving one column becomes a loop over every row. So this one stacks each column into its own vertical block and stands the blocks side by side, then lifts only the block you grabbed by 16px and lightens its background a step. The blocks in between step aside by one column width, and a dashed slot marks where it will land.

pointerdowntranslateXbox-shadow
function rdMove(e) {
  var dx = e.clientX - rdAt;
  rdEl.style.transform = 'translateX(' + dx + 'px)';
  rdTo = Math.max(0, Math.min(rdList().length - 1, rdFrom + Math.round(dx / rdW)));
  rdList().forEach(function (el, i) {
    if (el === rdEl) return;
    var shift = 0;
    if (rdTo > rdFrom && i > rdFrom && i <= rdTo) shift = -rdW;
    if (rdTo < rdFrom && i >= rdTo && i < rdFrom) shift = rdW;
    el.style.transform = 'translateX(' + shift + 'px)';
  });
}

04Menu where unchecking drops a column

Delete a hidden column with display: none and the remaining columns spread out however they like. This one rewrites the row track to 1fr 1fr 0fr instead, folding that one slot to nothing, with min-width: 0 and clipping on the cells so no text leaks out of a zero-width box. The last remaining column refuses to switch off.

aria-expandedpadding-bottomsteps(1, end)
function cvPaint() {
  var live = 0, track = [];
  ['1', '2', '3'].forEach(function (k) { if (cvOn[k]) live++; track.push(cvOn[k] ? '1fr' : '0fr'); });
  [].forEach.call(cvGrid.querySelectorAll('[data-col]'), function (el) {
    el.classList.toggle('is-off', !cvOn[el.dataset.col]);
  });
  cv.style.setProperty('--t', track.join(' '));
  cvBtn.querySelector('.cv__n3').textContent = String(live);
}

05Drag a rectangle of cells and copy it

The cell you pressed is the anchor and the cell under the pointer is the opposite corner, so the rectangle is just the min and max of the two row numbers and the two column numbers. The outline is one cell-sized element stretched with scale(columns, rows) rather than four edges computed separately. Let go and the rows join with newlines, the columns with tabs, and the whole block goes to the clipboard.

pointermovescaleclipboard
let count = 0;
let sum = 0;
data.forEach((row, r) =>
  row.forEach((n, c) => {
    if (inside(r, c)) {
      count += 1;
      sum += n;
    }
  })
);
const fmt = (n: number, s: number) => `${n} ${n === 1 ? cellWord : cellsWord} · ${sumWord} ${s}`;
const boxStyle: CSSProperties = sel
  ? {
      opacity: 1,
      transform: `translate(${sel.c0 * CELL_W}px, ${(sel.r0 + 1) * CELL_H}px) scale(${
        sel.c1 - sel.c0 + 1
      }, ${sel.r1 - sel.r0 + 1})`,
    }
  : {};

06Per-column filters that leave a pill behind

Once two conditions are on, people forget within seconds why the list got so short. So the conditions in force stack up as pills above the table, and filtered-out rows are folded to zero height rather than deleted. The row rules are drawn with an inset shadow instead of a border for the same reason: a border survives a zero height as a stubborn one-pixel line.

toLowerCasetranslateYsteps(1, end)
const keeps = (row: string[]) =>
  q.every((v, c) => {
    const t = v.trim().toLowerCase();
    return !t || row[c].toLowerCase().indexOf(t) >= 0;
  });

const onType = (c: number, value: string) => {
  setLive(false);
  setQ(q.map((v, k) => (k === c ? value : v)));
};

const chipText = (c: number, v: string) => `${head[c]} ${v}`;

07Cells painted and barred by how big the number is

Decide the background, the bar length, and the word badge in three separate places and the three will disagree. Here the bands are written once, and one function turns a value into a level that settles all three. The level is stamped on the cell as an attribute, and the stylesheet only ever reads that attribute.

scaleXsteps(1, end)data-level
const grade = (v: number) => (v >= 80 ? 3 : v >= 60 ? 2 : 1);
const word = (level: number) => (level === 3 ? bestWord : level === 2 ? goodWord : watchWord);
const barVars = (v: number): BarVars => ({ "--v": String(v / 100) });

const bump = (i: number) => {
  setLive(false);
  setData((prev) =>
    prev.map((r, n) => (n === i ? { name: r.name, value: r.value >= 95 ? 20 : r.value + 15 } : r))
  );
};

08Summary row stuck to the floor that recounts

The summary row sits inside the scroll box and is pinned with position: sticky. Put it outside and it is just a row sitting underneath, with no relationship to scrolling at all. The remaining scroll distance decides the shadow: more than 2px left and an upward shadow rests on that row, none at the end.

position: stickybox-shadowtranslateY
function smSum() {
  var picked = smRows().filter(function (r) { return r.classList.contains('is-pick'); });
  var list = picked.length ? picked : smRows();
  smAll.textContent = String(list.reduce(function (n, r) { return n + (+r.dataset.v); }, 0));
  document.getElementById('sm-sum').classList.toggle('is-part', picked.length > 0);
}
function smShade() {
  var left = smView.scrollHeight - smView.scrollTop - smView.clientHeight;
  sm.classList.toggle('is-more', left > 2);
}
smView.addEventListener('scroll', smShade);

09Focus outline that walks cells with arrow keys

Make all nine cells tab stops and one table catches a keyboard user nine times over. Only the current cell is a tab stop here; the rest are taken out of the order, and the arrow keys do the traveling between them. The visible outline is not switched on and off per cell either — it is one element that slides.

role="grid"tabindextranslate
function kgGo(r, c) {
  kgAt = { r: Math.max(0, Math.min(2, r)), c: Math.max(0, Math.min(2, c)) };
  var i = kgAt.r * 3 + kgAt.c;
  kgCells().forEach(function (el, n) { el.tabIndex = n === i ? 0 : -1; });
  kgRing.style.transform = 'translate(' + (kgAt.c * KG_W) + 'px, ' + ((kgAt.r + 1) * KG_H) + 'px)';
  kgRing.style.opacity = '1';
  kgCells()[i].focus({ preventScroll: true });
}
kgGrid.addEventListener('keydown', function (e) {
  var K = { ArrowRight: [0, 1], ArrowLeft: [0, -1], ArrowDown: [1, 0], ArrowUp: [-1, 0] }[e.key];
  if (K) { e.preventDefault(); kgGo(kgAt.r + K[0], kgAt.c + K[1]); return; }
  if (e.key === 'Home') { e.preventDefault(); kgGo(kgAt.r, 0); }
  if (e.key === 'End') { e.preventDefault(); kgGo(kgAt.r, 2); }
});

Where it breaks — the trap

The spot that breaks most often is the instant two rows swap and pass through each other. In 01, when both tied pairs trade places at the same moment, there is a frame in the middle where four rows look like two. Both row backgrounds are opaque, so whichever is painted on top hides the other completely. In the 24-frame preview, that frame happens to be the one that changed the most, which is exactly how it got picked as the cover image. The fix was to push the two swaps 20% apart in time and give only the upward-moving row a shadow and a stacking order, so you can see which one is passing.

The second trap is the mirror image. Make switching something on and switching it off equally strong and the off is always the bigger change. Opening the menu in 04 reveals only the menu, but closing it reveals the menu's own area plus all of the table that was hidden behind it. 05 had the same problem: nine cells going dark at once made an empty table with nothing selected the most-changed frame. The remedy is the same in both. Leave the on alone and stagger the off. In 04 the inner box dims first and only then does the outer one cut away, and in 05 the strip under the table steps down through its counts at the same instants the rectangle shrinks from 3×3 to 2×2 to 1×1. Fade the label out later than the box and you are left with a frame claiming nine cells beside a rectangle covering four.

The third trap is keeping a column width in two places. The first draft of 02 tracked the cell width and the guide line as separate values, and every fast drag put the line a few pixels ahead of or behind the edge even though both are painted in the same frame. Merging them into one custom property and feeding the guide from it made the drift vanish. The custom property also has to be registered with @property as a length. Without that registration the browser treats the value as a plain token, so it jumps straight from one keyframe to the next with nothing in between.

Last is the narrow screen. These parts sit in the article at 480×300 and shrink to 320×200 on a phone. Once the stage padding is gone there are only 174px of height to work with, and measuring all nine at 320px wide gave heights between 108px and 164px. The tallest was 06 at 164px, with its filter row and its pill row stacked above the table, leaving 10px of slack. So on a narrow screen the spacing goes down, never up. Column widths shrink and cell side padding drops from 8px to 4px, and the vertical gaps are left alone. Keeping nine sets of rules per screen size would be unmaintainable, so they are collected in one folder whose archive password is g445uude, and inside it the vanilla build and the React build you are looking at carry the same values.

Accessibility (reduced-motion)

On a screen with prefers-reduced-motion: reduce turned on, all nine stop looping and keep only the resulting state. The sort indicator stands in its direction without rotating, the bars sit at their length without growing, and the traveling outline stops sliding while still showing which cell it is on. Losing the motion must not mean losing the fact that this is the column being sorted on.

With a table, the markup itself is the accessibility work. All nine sit inside a role="grid", with role="row" and role="gridcell" on the rows and cells and role="columnheader" on the headings. Two of them depart from that to match how they are actually built: 01, whose sortable headings are buttons carrying aria-sort, and 03, which is stacked out of columns rather than rows. Not relying on color alone comes from the same place: 07 puts a word badge next to the background tint, and in 08 the total itself changes rather than only the shadow.

Item To the eye To a screen reader Input
01 Priority sort Triangle direction + order badge aria-sort toggled between ascending and descending Click, shift-click
02 Column width Guide line and cell width role="separator" with a name Drag, left and right arrows by 8px
03 Column reorder Lifted column + dashed slot Header names change order in the document Pointer drag
04 Column visibility Slot folding out of the table aria-expanded and role="checkbox" Click
05 Range select Blue rectangle + cell tint Strip says the count and the sum in words Drag and release
06 Column filters Rows folding + pills Each filter box has its own aria-label Per keystroke
07 Conditional format Tint, bar, badge The level word sits beside the cell as text Cell click
08 Summary row Pinned floor + shadow The total switches to the partial sum Scroll, row click
09 Keyboard walk Thick outline Focus really moves to the cell Arrows, Home, End

Which roles and states a grid-shaped table needs are set out in the MDN grid role reference. Other list and table parts are collected under the dashboard category, and the parts with real JavaScript behind them under the JS category.

FAQ

Can sorting be done in CSS alone?

Reordering the rows, yes. Give each row an order value with a flex parent and the paint order changes. What CSS cannot do is decide those values, because ranking four scores from first to last is a comparison, and the comparison ends up in JavaScript anyway. There is a worse catch: an order applied that way changes only what is drawn. A screen reader still reads the document order, so the first place you see and the first place you hear are different rows. That is why 01 sorts the real array and redraws.

Does storing a column width in pixels break when the window resizes?

It does. That is why 02 clamps the width to a floor of 88px and a ceiling of 200px and never lets a drag past either end. If you plan to persist the value, store a fraction of the table width instead of a pixel count, because a fraction survives a change of screen size. The one thing a fraction cannot do is guarantee a usable minimum, so keep a pixel floor alongside it or a column on a small phone will end up narrower than a single character.

Is this fine with more than a thousand rows?

The filter in 06 walks every row, so it slows down in direct proportion to the row count. A few hundred rows are nothing, but past that you want to draw only the rows currently on screen. The separate scroll box in 08 is where that starts. Knowing the box height and the row height is enough to work out which row is at the top right now, and you render only that window. The single thing to watch is the total: it has to be added up from the whole source array, not from the rows you happened to draw.

Enter the archive password

The password is inside this article. You will find it as you read.