GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

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 reach for resizable table columns and widen it (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). When the values are files this table moves into File Tree CSS Views, and when comparison is the point Before After Slider UIs takes the cell.

01Priority sort that holds two columns at once

What these data table ui examples show

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.

Priority sort that holds two columns at once
Open in a new tab

01 · Priority sort that holds two columns at once — Measured area 4.558% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-sorttranslateYrotate

Where to look in the code

The comparator walks the key list from the front and returns on the first key where the two values differ.

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

What happens on screen

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.

Column width you drag from the header edge
Open in a new tab

02 · Column width you drag from the header edge — Measured area 3.553% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

grid-template-columnspointermovetranslateX

How to read the code

Write the width twice and the line and the edge will drift apart.

@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

What moves

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.

Column reorder that lifts the header
Open in a new tab

03 · Column reorder that lifts the header — Measured area 24.881% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

pointerdowntranslateXbox-shadow

What to change

The blocks in between step aside by one column width, and a dashed slot marks where it will land.

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

What you see

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.

Menu where unchecking drops a column
Open in a new tab

04 · Menu where unchecking drops a column — Measured area 7.581% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-expandedpadding-bottomsteps(1, end)

Where to look in the code

The last remaining column refuses to switch off.

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

What happens on screen

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.

Drag a rectangle of cells and copy it
Open in a new tab

05 · Drag a rectangle of cells and copy it — Measured area 13.154% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

pointermovescaleclipboard

How to read the code

Let go and the rows join with newlines, the columns with tabs, and the whole block goes to the clipboard.

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

What moves

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.

Per-column filters that leave a pill behind
Open in a new tab

06 · Per-column filters that leave a pill behind — Measured area 21.649% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

toLowerCasetranslateYsteps(1, end)

What to change

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.

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

What you see

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.

Cells painted and barred by how big the number is
Open in a new tab

07 · Cells painted and barred by how big the number is — Measured area 5.962% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

scaleXsteps(1, end)data-level

Where to look in the code

The level is stamped on the cell as an attribute, and the stylesheet only ever reads that attribute.

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

What happens on screen

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.

Summary row stuck to the floor that recounts
Open in a new tab

08 · Summary row stuck to the floor that recounts — Measured area 9.225% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

position: stickybox-shadowtranslateY

How to read the code

The remaining scroll distance decides the shadow: more than 2px left and an upward shadow rests on that row, none at the end.

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

What moves

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.

Focus outline that walks cells with arrow keys
Open in a new tab

09 · Focus outline that walks cells with arrow keys — Measured area 5.625% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

role="grid"tabindextranslate

What to change

The visible outline is not switched on and off per cell either — it is one element that slides.

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. The drag-and-drop feel of headers reuses the same hand in the drag sorting of Portfolio Gallery HTML CSS. 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 table sorting javascript be replaced by 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. The switch that holds a few views of the table is Segmented Control UI Patterns.

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.