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

Collapsible Table Row: 9 UI Patterns

Collapsible table row: One of nine grows a detail height as a row's expand value changes, one shrinks a removed row to zero while rows below fill the gap,

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

None of the nine demos below reach out to a server, store anything in localStorage, or roll a random number — every visible change traces back to one state value flipping and a handful of constants (an interpolation speed, a stagger interval, a spring's stiffness and damping) deciding how that flip looks on screen. That constraint is what makes them useful as a checklist: if you can name the value that changed, you can usually spot which of these nine patterns you are actually looking at.

Anatomy of nine patterns — which value changes and when

Eight of the nine drive a per-frame requestAnimationFrame loop that reads a target derived from state and lerps (or, for the selection spring, integrates) a current value toward it — expand height, the selection spring, insert position, delete height, the inline-edit morph, pagination slide, empty-state fade, and sticky shadow; only group collapse skips the loop, setting a child row's height, opacity, and transform once per click and letting a plain CSS transition carry it the rest of the way, with a per-child transitionDelay staggering when each row's transition starts. A table row is a narrow strip, so every one of these patterns spends its motion budget on height, vertical position, or a shadow/color value — nothing here rotates, scales past 1, or throws confetti.

Code per pattern

01Row expand-value toggle — when an item's expand value changes, the detail area's height value grows or shrinks along with it

Clicking a row toggles its openIdx flag, and retarget() re-measures that row's real scrollHeight so the target height is never a guess. A LERP of 0.22 pulls the current height toward that target every frame, which is why the panel eases open instead of snapping to its full size.

Row expand-value toggle — when an item's expand value changes, the detail area's height value grows or shrinks along with it
Open in a new tab

01 · Row expand-value toggle — when an item's expand value changes, the detail area's height value grows or shrinks along with it — Measured area 19.903% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

scrollHeightLERP 0.22.tre__detail
  // Constants: detail-area entry cap (px, generous; real height comes from measured scrollHeight) · interpolation speed · auto-loop interval
  var MAX_H = 80;
  var LERP = 0.22;
  var AUTO_TOGGLE_MS = 1100;

  var openIdx = [false, false, false];
  var h = [0, 0, 0]; // each detail area's current (interpolating) height value
  var target = [0, 0, 0];

  // Calculation layer: when the open value changes, the target height is re-measured from that row's real scrollHeight
  function retarget(i) {
    if (openIdx[i]) {
      details[i].style.height = 'auto';
      var natural = Math.min(MAX_H, details[i].scrollHeight);
      details[i].style.height = h[i].toFixed(1) + 'px';
      target[i] = natural;
    } else {
      target[i] = 0;
    }
  }

What the numbers say

Item 01 covers 19.9% of the frame on average and moves in 6 of 23 sampled frames, the signature of a slow height lerp that most of the time sits still at its resting value.

02Group collapse-value toggle — when a group header's collapse value changes, the display value of every row under it changes together

Every child row shares one boolean, but apply() still gives each of them a different transitionDelay computed as i * STAGGER_MS, so a single collapse value fans out into a staggered cascade instead of every row moving at once.

Group collapse-value toggle — when a group header's collapse value changes, the display value of every row under it changes together
Open in a new tab

02 · Group collapse-value toggle — when a group header's collapse value changes, the display value of every row under it changes together — Measured area 17.353% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password

STAGGER_MS 70transitionDelay.tgc__child
  // Constants: child row height (px) · stagger interval between children (ms) · auto-loop interval
  var CHILD_H = 30;
  var STAGGER_MS = 70;
  var AUTO_TOGGLE_MS = 1300;

  var collapsed = false;

  // Calculation layer: when the collapse value turns on, child count x stagger gives each row a different display-transition time
  function apply() {
    children.forEach(function (el, i) {
      var delay = i * STAGGER_MS;
      el.style.transitionDelay = delay + 'ms';
      if (collapsed) {
        el.style.height = '0px';
        el.style.opacity = '0';
        el.style.transform = 'translateY(-' + (CHILD_H * 0.3).toFixed(1) + 'px)';
      } else {
        el.style.height = CHILD_H + 'px';
        el.style.opacity = '1';
        el.style.transform = 'translateY(0px)';
      }
    });
    group.classList.toggle('tgc__group--collapsed', collapsed);
  }

What the numbers say

Item 02 reaches 17.4% cumulative area at an average intensity of 77.3 across 7 moved frames — a shorter animated window than item 01, but a stronger per-frame change because several rows shift together.

03Selection-value highlight toggle — when a row's selection value turns on, its highlight-color value deepens, and it returns to the original value when off

tick() does not just cross-fade a color — it integrates a spring equation (acc = -K*(pos-target) - D*vel) every frame, so the highlighted background overshoots past its final tone and settles back, which a plain CSS transition cannot produce.

Selection-value highlight toggle — when a row's selection value turns on, its highlight-color value deepens, and it returns to the original value when off
Open in a new tab

03 · Selection-value highlight toggle — when a row's selection value turns on, its highlight-color value deepens, and it returns to the original value when off — Measured area 21.536% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

K 0.26 / D 0.62vel.tsh__row
  // Calculation layer: integrate a spring equation toward the target (0 or 1) every frame (acc = -k*(pos-target) - d*vel)
  // to produce an overshoot that slightly passes 1 and returns -- not a plain tween
  function tick() {
    for (var i = 0; i < 4; i++) {
      var target = selected[i] ? 1 : 0;
      var acc = -K * (pos[i] - target) - D * vel[i];
      vel[i] += acc * DT;
      pos[i] += vel[i] * DT;
      var clampedForColor = Math.max(0, Math.min(1, pos[i]));
      var over = Math.max(0, pos[i] - 1);
      var b = baseColor(), h = hiColor();
      var r = b[0] + (h[0] - b[0]) * clampedForColor;
      var g = b[1] + (h[1] - b[1]) * clampedForColor;
      var bl = b[2] + (h[2] - b[2]) * clampedForColor;
      rows[i].style.background = 'rgb(' + r.toFixed(0) + ',' + g.toFixed(0) + ',' + bl.toFixed(0) + ')';
      rows[i].style.boxShadow = over > 0.001 ? ('inset 0 0 0 ' + (over * 6).toFixed(1) + 'px rgba(255,255,255,' + Math.min(0.6, over).toFixed(2) + ')') : 'none';
    }
    requestAnimationFrame(tick);
  }
  requestAnimationFrame(tick);

What the numbers say

Item 03 is the densest of the nine at 21.5% cumulative area, 117.9 average intensity, and 8 of 23 frames moved — the spring's overshoot keeps recalculating color and shadow even after a click has already landed.

04Row-insert and position values changing together — when a new row is added, its position value settles into place while the rows below shift their position values too

Every row only knows its own order index; tick() lerps each row's y toward order * ROW_H, so inserting a row at the top is really just one order value changing, and every other row's position value follows because its own target moved.

Row-insert and position values changing together — when a new row is added, its position value settles into place while the rows below shift their position values too
Open in a new tab

04 · Row-insert and position values changing together — when a new row is added, its position value settles into place while the rows below shift their position values too — Measured area 11.482% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

SLOTS 4order.tis__row
  function tick() {
    rows.forEach(function (r) {
      var target = r.order * ROW_H;
      r.y += (target - r.y) * LERP;
      var opacity = r.order >= SLOTS ? Math.max(0, 1 - (r.y - (SLOTS - 1) * ROW_H) / ROW_H) : 1;
      r.el.style.transform = 'translateY(' + r.y.toFixed(1) + 'px)';
      r.el.style.opacity = opacity.toFixed(2);
    });
    for (var i = rows.length - 1; i >= 0; i--) {
      var r = rows[i];
      if (r.order >= SLOTS && Math.abs(r.order * ROW_H - r.y) < 1) {
        r.el.remove();
        rows.splice(i, 1);
      }
    }
    requestAnimationFrame(tick);
  }
  requestAnimationFrame(tick);

What the numbers say

Item 04 moves in 10 of 23 frames — the most of any pattern here — at 11.5% cumulative area and 103.8 average intensity, because rows keep resettling position for several frames after each insert.

05Row-delete and height values changing together — when a row's existence value ends, its height value shrinks to zero while the rows below fill the gap with their position values

Deleting a row does not remove it immediately: r.removing flips true, r.h lerps toward 0 at H_LERP, and only once that height value is under 0.5 does the element actually leave the DOM, which is why the rows underneath visibly slide up to fill the gap rather than jumping.

Row-delete and height values changing together — when a row's existence value ends, its height value shrinks to zero while the rows below fill the gap with their position values
Open in a new tab

05 · Row-delete and height values changing together — when a row's existence value ends, its height value shrinks to zero while the rows below fill the gap with their position values — Measured area 5.46% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

POS_LERP 0.22H_LERP 0.26.tdc__inner
  function tick() {
    for (var i = rows.length - 1; i >= 0; i--) {
      var r = rows[i];
      var targetY = r.order * ROW_H;
      r.y += (targetY - r.y) * POS_LERP;
      var targetH = r.removing ? 0 : ROW_H;
      r.h += (targetH - r.h) * H_LERP;
      r.el.style.transform = 'translateY(' + r.y.toFixed(1) + 'px)';
      r.inner.style.height = Math.max(0, r.h).toFixed(1) + 'px';
      r.inner.style.opacity = (r.h / ROW_H).toFixed(2);
      if (r.removing && r.h < 0.5) {
        r.el.remove();
        rows.splice(i, 1);
      }
    }
    requestAnimationFrame(tick);
  }
  requestAnimationFrame(tick);

What the numbers say

Item 05 sits at 5.5% cumulative area, 75.5 average intensity, across 6 of 23 frames — a lighter footprint than item 04's insert, since only one row's height is shrinking at a time instead of every row resettling.

06Edit-state value toggle — when a cell's edit-state value changes, a text value's spot switches to an input value

Clicking a cell sets c.editing = true, but the switch is not instant: tick() reads the text span's and the input's real position and size with getBoundingClientRect(), then lerps a .tie__box overlay's transform and size between those two measured rects by LERP at 0.25 every frame while the text and input cross-fade their opacity, and the input only accepts clicks once that morph passes 60% — blur sets editing back to false, and Enter inside the input calls blur() to commit it.

Edit-state value toggle — when a cell's edit-state value changes, a text value's spot switches to an input value
Open in a new tab

06 · Edit-state value toggle — when a cell's edit-state value changes, a text value's spot switches to an input value — Measured area 1.251% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

getBoundingClientRectLERP 0.25.tie__box
  function tick() {
    cells.forEach(function (c) {
      var target = c.editing ? 1 : 0;
      c.prog += (target - c.prog) * LERP;
      if (Math.abs(target - c.prog) < 0.01) c.prog = target;
      var m = measure(c);
      var t = m.textRect, i = m.inputRect;
      var l = t.l + (i.l - t.l) * c.prog;
      var top = t.t + (i.t - t.t) * c.prog;
      var w = t.w + (i.w - t.w) * c.prog;
      var h = t.h + (i.h - t.h) * c.prog;
      c.box.style.transform = 'translate(' + l.toFixed(1) + 'px,' + top.toFixed(1) + 'px)';
      c.box.style.width = w.toFixed(1) + 'px';
      c.box.style.height = h.toFixed(1) + 'px';
      c.text.style.opacity = (1 - c.prog).toFixed(2);
      c.input.style.opacity = c.prog.toFixed(2);
      c.input.style.pointerEvents = c.prog > 0.6 ? 'auto' : 'none';
    });
    requestAnimationFrame(tick);
  }
  requestAnimationFrame(tick);

What the numbers say

Item 06 covers only 1.3% cumulative area at 68.3 average intensity across 8 of 23 frames — the morph box is small and the text/input cross-fade covers little screen space, even though it runs the same per-frame lerp math as items 01, 04, 05, 07, and 08.

07Page value change and row-position value shift — when the page value changes, previous rows' position values exit and new rows' position values enter

Every visible row carries a targetX and a delayUntil timestamp; changing pages sets new target positions and staggers delayUntil per row, so tick() skips a row entirely until its own delay has passed, which is what keeps the whole page from sliding as one flat block.

Page value change and row-position value shift — when the page value changes, previous rows' position values exit and new rows' position values enter
Open in a new tab

07 · Page value change and row-position value shift — when the page value changes, previous rows' position values exit and new rows' position values enter — Measured area 2.208% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

STAGGER_MS 60delayUntil.tps__row
  function tick() {
    var now = performance.now();
    live.forEach(function (r) {
      if (now < r.delayUntil) return;
      r.x += (r.targetX - r.x) * LERP;
      r.el.style.transform = 'translateX(' + r.x.toFixed(1) + 'px)';
      r.el.style.opacity = String(Math.max(0, 1 - Math.abs(r.x) / W));
    });
    requestAnimationFrame(tick);
  }
  requestAnimationFrame(tick);

  renderInitial();
  viewport.style.height = (4 * ROW_H) + 'px';

  document.getElementById('tpsPrev').addEventListener('click', function () {
    viewport.classList.remove('is-demo');
    goTo(pageIndex - 1);
  });

What the numbers say

Item 07 measures 2.2% cumulative area, 91.9 average intensity, across 6 of 23 frames — the shortest active window of the nine, since the per-row delay keeps most rows idle until their own turn.

08Empty-list state value toggle — when the no-rows state value turns on, an empty-state message's appearance value gradually rises

tick() checks rows.length === 0 every frame and lerps a single emptyProg value toward 1 or 0, so the empty-state message does not just appear — its opacity and a small upward slide both trace that one interpolating value.

Empty-list state value toggle — when the no-rows state value turns on, an empty-state message's appearance value gradually rises
Open in a new tab

08 · Empty-list state value toggle — when the no-rows state value turns on, an empty-state message's appearance value gradually rises — Measured area 17.008% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

emptyProgLERP 0.2.tef__empty
  // Calculation layer: when the no-rows state value turns on, the empty-message appearance value (emptyProg)
  // rises from 0 by 20% of the remaining distance every frame -- see tick() in index.html
  function tick() {
    for (var i = rows.length - 1; i >= 0; i--) {
      var r = rows[i];
      var targetH = r.removing ? 0 : ROW_H;
      r.h += (targetH - r.h) * LERP;
      r.el.style.height = Math.max(0, r.h).toFixed(1) + 'px';
      r.el.style.opacity = (r.h / ROW_H).toFixed(2);
      if (r.removing && r.h < 0.5) {
        r.el.remove();
        rows.splice(i, 1);
      }
    }
    var noRows = rows.length === 0;
    var target = noRows ? 1 : 0;
    emptyProg += (target - emptyProg) * LERP;
    empty.style.opacity = emptyProg.toFixed(2);
    empty.style.transform = 'translateY(' + ((1 - emptyProg) * 10).toFixed(1) + 'px)';
    requestAnimationFrame(tick);
  }
  requestAnimationFrame(tick);

What the numbers say

Item 08 reaches 17.0% cumulative area at 33.6 average intensity across 9 of 23 frames — a wide but gentle motion, since the whole empty-state block fades rather than any single row snapping.

09Scroll-position and shadow values linked — once the scroll-position value crosses a set threshold, the header pins in place and its shadow value deepens

measure() turns raw scroll position into a 0-1 ratio, but only the 0.18-0.5 slice of that ratio feeds pinTarget — below 0.18 the header has no shadow at all, and above 0.5 the shadow value is already maxed, so the visible transition happens in a narrower band than the scroll range itself.

Scroll-position and shadow values linked — once the scroll-position value crosses a set threshold, the header pins in place and its shadow value deepens
Open in a new tab

09 · Scroll-position and shadow values linked — once the scroll-position value crosses a set threshold, the header pins in place and its shadow value deepens — Measured area 22.126% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

scrollTop / maxScrollLERP 0.18.tss__head
  // Calculation layer: filter the scroll-position value (scrollTop / maxScroll) down to only the 0.18-0.5 band to
  // build a header-pin value, then lerp shadow blur/drop by 18% of the remaining distance every frame from that value
  function tick() {
    measure();
    var ratio = viewport.scrollTop / maxScroll;
    var pinTarget = ratio < 0.18 ? 0 : ratio > 0.5 ? 1 : (ratio - 0.18) / 0.32;
    shadow += (pinTarget - shadow) * LERP;
    var blur = (shadow * 14).toFixed(1);
    var drop = (shadow * 6).toFixed(1);
    var alpha = (shadow * 0.5).toFixed(2);
    head.style.boxShadow = '0 ' + drop + 'px ' + blur + 'px rgba(10, 15, 30, ' + alpha + ')';
    head.style.backgroundColor = 'rgba(35, 31, 40, ' + (0.94 + shadow * 0.06).toFixed(2) + ')';
    requestAnimationFrame(tick);
  }
  requestAnimationFrame(tick);

What the numbers say

Item 09 leads every pattern here at 22.1% cumulative area and moves in all 23 of 23 sampled frames — the header shadow is never fully settled because ordinary scrolling keeps nudging the ratio inside that 0.18-0.5 band.

Where values reshape a pattern — expand height, delete duration, scroll threshold

Row expand-value toggle's LERP from 0.22 down to 0.08 stretches item 01's open animation until the detail area is still visibly growing well after the click — a heavier, more deliberate feel that suits a row holding a long block of text rather than a short summary.

Group collapse-value toggle's STAGGER_MS from 70 up to 220 turns item 02's cascade into something closer to a waterfall: each child row's own transitionDelay grows far enough apart that a group of five rows folds up one visible step at a time instead of nearly together.

Selection-value highlight toggle's D (damping) from 0.62 down to 0.18 lets item 03's spring keep more of its energy, so the highlight color overshoots harder and bounces past its target two or three times before settling — springy rather than a single clean fade. This is also the constant that separates a collapsible table row ui project that only cross-fades a background color from one that needs real physics: skipping the spring entirely still works for a plain highlight, but item 03 keeps recalculating pos and vel for several frames after the click event has already fired.

Beyond this one episode, Data Table UI Patterns (post 257) works a different axis of the same table surface — its rows change position through sort, resize, and column reorder rather than an expand or delete value, so a click there moves a column boundary instead of a height. Dashboard Components (post 226) works the widget axis: its cards toggle loading, empty, and refreshed states the way item 08 here toggles a no-rows state, but across a whole dashboard panel rather than one table row. Drag to Reorder UI (post 336) is the closest relative to item 04's insert-and-shift math, except its position value changes because a pointer drags it there, while item 04's position value changes because a new row was inserted at index zero — same lerp-to-order math, a different trigger. Reading how an expandable table row ui codebase actually assigns order and target values in a real editor is a smaller step once the zip is open, and that step is what r3p62vhd unlocks below.

Where it breaks

The instant the list goes empty

Item 05's own auto-loop only refills the table while its is-demo class is still attached, and that class is removed the moment a person clicks a delete button for real — delete every row by hand and the table stays empty for good, with no button left to bring a row back. Item 08 is built for exactly this moment instead: its click handler checks rows.length on every click and calls refill() once it hits zero, so the same "table now has nothing in it" state that traps item 05 is the entire reason item 08 exists.

Narrow screens

All nine demos carry a @media (max-width: 340px) rule in their SCSS, and because every pattern's motion lives in height, vertical position, or a shadow rather than horizontal transform, none of them need a different animation path on a narrow phone width — the same tick() or transition keeps running, only the row's own width and padding shrink under that breakpoint.

Reduced motion

Every one of the nine sets transition: none (or removes the animated property) under prefers-reduced-motion: reduce in CSS, and item 03 goes one step further by also checking matchMedia('(prefers-reduced-motion: reduce)') in its JavaScript, skipping the spring integration entirely rather than just cutting a CSS transition — the only item here where reduced motion changes which function runs, not just how long it runs.

Three variants and accessibility

Variant What changes What it doesn't touch
Row expand-value toggle, slow open (LERP 0.22 to 0.08) Item 01's open speed only retarget()'s scrollHeight measurement, item 01's markup
Group collapse-value toggle, waterfall (STAGGER_MS 70 to 220) Item 02's per-child delay spacing only apply()'s height/opacity/transform values, group markup
Selection-value highlight, bouncy (D 0.62 to 0.18) Item 03's spring damping only K stiffness, baseColor()/hiColor(), row markup

Across all nine, ARIA attributes only appear where a real state needs announcing — items 04, 05, and 07 — while nothing in this set uses disabled or tabindex, and only item 06's input listens for a keydown at all. None of the nine fetch data, read or write storage, or call Math.random, so every value driving these transitions is exactly the state you can see logged in the markup and the constants above it.

FAQ

Is a collapsible table row built with real CSS transitions or JavaScript animation?

Both appear in this set: item 02 alone relies on a plain CSS transition, setting a child row's height, opacity, and transform once per click and layering a per-child transitionDelay on top, while the other eight — including any "accordion table html" example driven by a spring or a scroll ratio — run a per-frame requestAnimationFrame loop instead, because a single CSS transition cannot integrate a spring equation or filter a scroll ratio down to one band.

Why does an expandable table row's detail area animate to different heights instead of one fixed value?

Because retarget() reads each row's real scrollHeight rather than assuming a constant, so a row with three lines of detail text opens to a different height value than a row with one line — the animation is always lerping toward a measured target, not a guessed one.

Does an accordion table ui need a physics library to get the spring overshoot in item 03?

No — tick() integrates acc = -K*(pos-target) - D*vel by hand every frame with two constants and two accumulating variables, which is the entire spring; nothing here calls into a physics or animation library to produce the overshoot.

Sources: MDN — Window: requestAnimationFrame() method · MDN — prefers-reduced-motion · MDN — transition-delay

Enter the archive password

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