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

9 Case Study Page Design Sections (Copy-Paste)

Nine case-study sections that compute: the hero recounts its metric frame by frame, tiles size from a computed impact score, and quotes reverse-solve their

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

Every scene below cycles a fixed array of scenarios through setInterval, so the same fake project names and the same made-up numbers land in the same spot on every capture — nothing here reads Math.random() except the video card's seeded noise. Switching an industry tab and pressing the video card's play button are the only two real interactions in the set; the other seven sections replay on their own. prefers-reduced-motion keeps every score, ratio, and percentage recomputing while it only drops the travel that would otherwise animate the result into view.

Anatomy of a case study page — what nine sections compute

None of these nine sections prints a number once and leaves it sitting in the markup. The same small set of arithmetic moves — a ratio, a normalized score, a measured width — keeps resurfacing under different labels, and once that's visible, all nine read as one idea wearing nine costumes.

Why a headline metric rolls up through easing instead of jumping straight to the answer

Passing the elapsed time ratio through 1 - (1 - t) ^ EASE_POW on every animation frame is what turns item 01's target number into a rolling count instead of a number that simply appears, and the moment an extra digit shows up, a canvas measureText call remeasures that string so the digit column never gets squeezed.

Why tile size comes from a normalized score, not a class picked by hand

Item 02 divides each story's result by the biggest result in its dataset, raises that fraction to IMPACT_WEIGHT, and only then decides how many grid columns and rows a tile spans — so a dataset with a runaway top performer reshapes the whole grid instead of leaving every tile the same size.

Why a measured pixel width decides where an underline or a marquee lands, not a guess

Item 05's tab underline and item 08's logo marquee both read getBoundingClientRect before doing any math, because a hard-coded offset or a fixed loop duration breaks the instant a label's text or a logo count changes to something nobody measured in advance. A case study page design template that ships only a static screenshot hides all of this; every section here keeps recomputing long after the first paint.

Code per section

Every block below is copied straight out of that item's own index.html — not retyped from memory — and the paired prompt in the zip regenerates the same file when handed to an AI coding tool. A case study page design inspiration board full of screenshots can't show this part: what happens after the first paint, not what the first paint looks like.

01Case hero — a top section that recomputes its headline metric every frame to count it up

As soon as this section enters view, the target metric gets recomputed frame by frame from zero, and the moment the rolling number gains a digit, the digit column's own rendered width gets remeasured so nothing overlaps.

Case hero — a top section that recomputes its headline metric every frame to count it up
Open in a new tab

01 · Case hero — a top section that recomputes its headline metric every frame to count it up — Measured area 6.476% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

getContext('2d')measureTextrequestAnimationFrame

What the numbers say

Cumulative area lands at 6.48% (mean intensity 165.7, the brightest of the nine) across 17 of 23 frame-to-frame comparisons — a large bold number rolling upward reads as the most visually intense of the set even with a modest footprint.

function applyValue(v) {
  numEl.textContent = String(v);
  var digits = String(v).length;
  if (digits !== lastDigits) {
    lastDigits = digits;
    var px = parseFloat(getComputedStyle(numEl).fontSize) || 44;
    var w = measureWidth(String(v), px);
    wrapEl.style.setProperty('--num-w', Math.ceil(w) + 'px');
  }
}

function runCycle() {
  if (reduce) { applyValue(TARGET); return; }
  var start = null;
  function frame(ts) {
    if (start === null) start = ts;
    var t = Math.min((ts - start) / ANIM_MS, 1);
    var eased = 1 - Math.pow(1 - t, EASE_POW);
    var val = Math.round(TARGET * eased);
    applyValue(val);
    if (t < 1) requestAnimationFrame(frame);
  }
  applyValue(0);
  requestAnimationFrame(frame);
}

02Story grid — a grid that works out its own impact score to size each tile

Comparing every story's result figure against this dataset's own maximum works out a 0-to-1 score, and each score tier picks its own column span, row span, padding, and number size rather than sharing one fixed tile shape.

Story grid — a grid that works out its own impact score to size each tile
Open in a new tab

02 · Story grid — a grid that works out its own impact score to size each tile — Measured area 52.497% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password

querySelectorAllclassList.addtransform-origin

What the numbers say

Cumulative area is the widest of the nine at 52.50% (max frame 48.82%) but mean intensity is the calmest at 46.9, over just 7 of 23 frames — a full grid reshuffle swaps between similarly toned panels rather than flashing bright.

function apply(values) {
  var max = Math.max.apply(null, values);
  tiles.forEach(function (tile, i) {
    var value = values[i];
    var normalized = max > 0 ? value / max : 0;
    var weighted = Math.pow(normalized, IMPACT_WEIGHT);
    var tier = tierFor(weighted);
    tile.style.gridColumn = 'span ' + tier.col;
    tile.style.gridRow = 'span ' + tier.row;
    tile.dataset.pad = tier.pad;
    tile.dataset.font = tier.font;
    var valEl = tile.querySelector('.tileVal');
    if (valEl) valEl.textContent = String(value);
    if (!reduce) {
      tile.classList.remove('tile--pulse');
      void tile.offsetWidth;
      tile.classList.add('tile--pulse');
    }
  });
}

03Metric highlight — a card that divides before and after to work out its own ratio

Dividing the gap between the before value and the after value by the before value works out the improvement ratio that sets the gauge's stroke offset, and the same ratio decides how many percentage points the badge prints next to it.

Metric highlight — a card that divides before and after to work out its own ratio
Open in a new tab

03 · Metric highlight — a card that divides before and after to work out its own ratio — Measured area 2.328% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

stroke-dashoffsetrequestAnimationFramefont-variant-numeric

What the numbers say

Cumulative area sits at 2.33% (max frame 0.71%, mean intensity 128.3) over 17 of 23 frames as three metric pairs cycle through — an underline-sized gauge stroke is a structurally small-change surface, not a defect.

function computeRatio(metric) {
  var delta = metric.lowerIsBetter ? (metric.before - metric.after) : (metric.after - metric.before);
  var improvement = delta / metric.before;
  return Math.max(0, Math.min(1, improvement / MAX_IMPROVEMENT));
}

function computePercent(metric) {
  var delta = metric.lowerIsBetter ? (metric.before - metric.after) : (metric.after - metric.before);
  return Math.round((delta / metric.before) * 100);
}

function applyGauge(ratio) {
  var offset = CIRC * (1 - ratio);
  fill.style.strokeDashoffset = String(offset);
}

function fmt(metric, value) {
  if (metric.unit === 'ms') return Math.round(value) + 'ms';
  return (Math.round(value * 10) / 10) + '%';
}

04Quote block — a quote card that reverse-solves its own font size from character length

Because every quote is a different length, a 14-round binary search measures real rendered widths with canvas measureText and works backward to the largest font size that still fits the card, instead of a font size typed in once and hoped for.

Quote block — a quote card that reverse-solves its own font size from character length
Open in a new tab

04 · Quote block — a quote card that reverse-solves its own font size from character length — Measured area 7.081% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

getContext('2d')measureTextclassList.add

What the numbers say

Cumulative area comes to 7.08% (max frame 5.93%, mean intensity 77.7) over 10 of 23 frames as three quotes of different lengths cycle through the same reverse-solve.

// Binary-search backward for the largest font size that still fits inside the target width.
function solveFontSize(text, targetWidth) {
  var lo = MIN_PX, hi = MAX_PX, best = MIN_PX;
  for (var i = 0; i < 14; i++) {
    var mid = (lo + hi) / 2;
    var w = widthAt(text, mid);
    if (w <= targetWidth - TOLERANCE) {
      best = mid;
      lo = mid;
    } else {
      hi = mid;
    }
  }
  return best;
}

function applyQuote(quote) {
  var targetWidth = textEl.clientWidth * 2.6; // character-width budget for roughly two lines
  var px = solveFontSize(quote.text, targetWidth);
  textEl.style.fontSize = px.toFixed(1) + 'px';
  textEl.textContent = quote.text;
  authorEl.textContent = quote.author;
}

05Industry tabs — tabs that recount per-industry totals on every switch

Every tab switch recounts how many stories belong to that industry against the running total for the percentage badge, and measures the newly selected tab's real position with getBoundingClientRect so the underline lands exactly under it.

Industry tabs — tabs that recount per-industry totals on every switch
Open in a new tab

05 · Industry tabs — tabs that recount per-industry totals on every switch — Measured area 2.14% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

getBoundingClientRectscaleXtranslateX

What the numbers say

Cumulative area is 2.14% (max frame 1.43%, mean intensity 143.3) over 9 of 23 frames — four industries cycle through, and a moving underline is naturally one of the smallest surfaces in the set.

function measureAndMove(idx) {
  var btn = buttons[idx];
  var wrapRect = tabsEl.getBoundingClientRect();
  var btnRect = btn.getBoundingClientRect();
  var x = btnRect.left - wrapRect.left;
  var w = btnRect.width;
  underlineEl.style.transform = 'translateX(' + x.toFixed(2) + 'px) scaleX(' + w.toFixed(2) + ')';
}

function selectTab(idx) {
  current = idx;
  buttons.forEach(function (b, i) {
    b.setAttribute('aria-selected', i === idx ? 'true' : 'false');
  });
  var tab = TABS[idx];
  var percent = Math.round((tab.count / TOTAL) * 100);
  countNumEl.textContent = String(tab.count);
  countLabelEl.textContent = tab.label + ' cases · ' + percent + '% of ' + TOTAL + ' total';
  measureAndMove(idx);
}

06Video card — a card that derives playback from viewport ratio and scrub position from scroll

IntersectionObserver measures, in 51 steps, how much of this card actually sits inside its scroll container's viewport, turns that into a ready-rate, and picks which seeded preview frame to redraw — there's no real video file behind it.

Video card — a card that derives playback from viewport ratio and scrub position from scroll
Open in a new tab

06 · Video card — a card that derives playback from viewport ratio and scrub position from scroll — Measured area 31.472% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

IntersectionObserverscrollTopgetContext('2d')

What the numbers say

Cumulative area is the second-widest of the nine at 31.47% (max frame 21.51%), tied for the most active at 23 of 23 frames, because the sine-wave autoplay scrubs the scroll position on nearly every tick.

function drawFrame(frameIndex) {
  if (!ctx) return;
  var rnd = mulberry32(frameIndex * 7919 + 11);
  var w = thumb.width, h = thumb.height;
  var cols = 8, rows = 5;
  var cw = w / cols, ch = h / rows;
  for (var r = 0; r < rows; r++) {
    for (var c = 0; c < cols; c++) {
      var v = rnd();
      var lightness = 22 + v * 34;
      var hue = 214 + rnd() * 18;
      ctx.fillStyle = 'hsl(' + hue.toFixed(0) + ', 38%, ' + lightness.toFixed(0) + '%)';
      ctx.fillRect(c * cw, r * ch, cw + 1, ch + 1);
    }
  }
  ctx.fillStyle = 'rgba(255,255,255,.06)';
  for (var i = 0; i < 3; i++) {
    var bx = rnd() * w, by = rnd() * h, br = 8 + rnd() * 18;
    ctx.beginPath();
    ctx.arc(bx, by, br, 0, Math.PI * 2);
    ctx.fill();
  }
}

07Related CTA — a recommender that works out relevance from shared tag counts

Counting how many tags the currently viewed story shares with every other card is the whole relevance score behind item 07, and the reorder that follows plays out purely through transforms using FLIP rather than an instant DOM reshuffle.

Related CTA — a recommender that works out relevance from shared tag counts
Open in a new tab

07 · Related CTA — a recommender that works out relevance from shared tag counts — Measured area 18.349% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

getBoundingClientRectclassList.addtranslate(

What the numbers say

Cumulative area is 18.35% (max frame 18.20%, mean intensity 59.7) over 8 of 23 frames as three tag sets cycle through the same overlap-and-reorder pass.

var scored = CARDS.map(function (card) {
  return { card: card, score: scoreFor(card, baseTags) };
});
scored.sort(function (a, b) { return b.score - a.score; });

// FLIP: record every card's position before the reorder happens.
var firstRects = {};
CARDS.forEach(function (card) {
  firstRects[card.id] = cardEls[card.id].getBoundingClientRect();
});

scored.forEach(function (entry) {
  var el = cardEls[entry.card.id];
  list.appendChild(el);
  var numEl = el.querySelector('.cardScoreNum');
  if (numEl) numEl.textContent = String(entry.score);
});

08Logo bar — a marquee that recomputes speed from logo count to keep loop time constant

Measuring one full set of logos' actual rendered width and dividing that against a baseline width is what item 08 turns into a loop duration, so the felt scroll speed in pixels per second stays the same no matter how many logos there are.

Logo bar — a marquee that recomputes speed from logo count to keep loop time constant
Open in a new tab

08 · Logo bar — a marquee that recomputes speed from logo count to keep loop time constant — Measured area 3.005% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

getBoundingClientRecttranslateX--marquee-duration

What the numbers say

Cumulative area is 3.00% (max frame 1.99%, mean intensity 130.8), tied for the most active at 23 of 23 frames, because the CSS marquee itself keeps moving on nearly every tick even though the measured area per frame is small.

var LOOP_SEC = 15;     // variant: 15 → 30 (one loop's duration at the baseline width)
var BASE_WIDTH = 560;  // baseline rendered width (px) — LOOP_SEC applies as-is at this width
var MIN_SEC = 8;
var MAX_SEC = 40;

function recompute() {
  var setA = track.querySelectorAll('.logo[data-set="a"]');
  var halfWidth = 0;
  setA.forEach(function (el) {
    var rect = el.getBoundingClientRect();
    var style = getComputedStyle(el);
    halfWidth += rect.width + parseFloat(style.marginRight || '0');
  });
  var duration = LOOP_SEC * (halfWidth / BASE_WIDTH);
  duration = Math.max(MIN_SEC, Math.min(MAX_SEC, duration));
  track.style.setProperty('--marquee-duration', duration.toFixed(2) + 's');
  var pxPerSec = halfWidth / duration;
  speedNote.textContent = 'Logos ' + setA.length + ' · loop ' + duration.toFixed(1) + 's · ' + Math.round(pxPerSec) + 'px/s';
}

09Case footer — a summary badge that works out the average across every story seen so far

Instead of dividing the whole history from scratch every time a new view lands, item 09 only ever updates a running sum and a running count, and divides those two to print the average.

Case footer — a summary badge that works out the average across every story seen so far
Open in a new tab

09 · Case footer — a summary badge that works out the average across every story seen so far — Measured area 0.79% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

classList.addsetIntervaltransform-origin

What the numbers say

Cumulative area is the smallest of the nine at 0.79% (max frame 0.53%, mean intensity 108.2) over 8 of 23 frames — a one-line summary badge is the smallest structural surface in the set by design, not by accident.

function step() {
  if (idx >= EVENTS.length) idx = 0;
  var value = EVENTS[idx];
  sum += value;
  count += 1;
  idx += 1;

  var average = sum / count;

  countNum.textContent = String(count);
  avgNum.textContent = average.toFixed(1);
  feedNote.textContent = 'Recent: impact ' + value + ' pts viewed';

  if (!reduce) {
    countNum.classList.remove('statNum--bump');
    avgNum.classList.remove('statNum--bump');
    // Force a reflow so the same class can restart its animation even when reapplied.
    void countNum.offsetWidth;
    countNum.classList.add('statNum--bump');
    avgNum.classList.add('statNum--bump');
  }
}

Where this actually gets used — a team building a case study page

Teams adding a case-study section to a portfolio or sales page usually land on these nine sections for one reason. They want a hero like 01 that counts a metric up on load, but they're stuck on how to keep the layout from breaking once the number grows past two digits.

Whoever stitches together cases from several industries on one page runs into what 05 solves — recounting the per-industry total every time a tab switches, and keeping that count correct as cases get added or removed.

For whoever wants a footer badge summarizing the average outcome across every case seen so far, 09's running-sum math is the whole feature — updating a value instead of recomputing everything from scratch every time another case comes into view is what keeps that badge cheap to render.

Three variants, and how they hold up under reduced motion

Variant What changes What it trades off
Hero countup easing exponent Item 01's EASE_POW from 2 to 4 2 climbs at a near-constant rate that reads as calm but understated; 4 jumps early and slows hard near the end for a strong "landing" on the final digit, which can also feel abrupt
Story grid impact weight Item 02's IMPACT_WEIGHT from .5 to 1.5 .5 flattens the score gap so tiles divide evenly but hides which story actually performed best; 1.5 makes the top scorer's tile balloon at the cost of squeezing the smaller ones
Logo bar loop duration Item 08's LOOP_SEC from 15 to 30 15 seconds at the 560px baseline shows every logo quickly but reads as busy; 30 seconds lets each logo be read at leisure but takes twice as long to see the full set

What reduced motion turns off, item by item

prefers-reduced-motion: reduce removes travel, not arithmetic, across all nine: item 01's digits jump straight to the eased target while the same easing math still runs underneath, and item 06 still measures the intersection ratio and picks a preview frame even though the sine-wave autoplay that drives the demo's own scrolling stops. Item 07's relevance score and FLIP-computed offsets still get worked out every cycle; only the transform that would otherwise animate the reorder gets skipped.

Where empty quotes and zero-result industries break the math

A computed layer only earns its keep when the edge cases feeding it are handled on purpose.

Why an empty quote string would still resolve to the card's largest allowed font size

widthAt returns 0 for an empty string, so every round of item 04's binary search keeps taking the branch that raises lo, and after 14 rounds best lands almost exactly at MAX_PX — an empty quote doesn't crash the reverse-solve, it just maxes it out for no visible text.

Why a zero-count industry still shows a clean 0% badge instead of breaking

Item 05 divides each tab's count by TOTAL, the sum across every industry, not by the tab's own count — so an industry with zero stories safely prints 0% instead of triggering a division by a number that could itself be zero.

Why a before value of zero would print "Infinity%" on the metric badge

computePercent divides the before/after gap straight by metric.before with no guard, so a metric that starts at exactly zero would produce Infinity for the percentage text even though computeRatio's own Math.min(1, improvement / MAX_IMPROVEMENT) clamp keeps the gauge stroke itself from visually breaking — the fix that keeps both numbers honest sits behind h4mdyx3h in the zip linked from this post.

Where numbers reshape layout — impact score, quote length, industry counts

None of these nine sections has a fixed layout sitting empty until a project fills it in; the numbers attached to a story decide what the stylesheet ends up reading.

Why a runaway top result claims two grid columns instead of one

Item 02's weighted score only crosses the .75 tier when a story's normalized result already sits well above the rest of its dataset, which is exactly why one standout project can end up spanning a two-by-two tile while an ordinary result stays at one-by-one.

Why a longer quote tightens its own line spacing instead of overflowing the card

solveFontSize's 14-round search always lands on a size that fits textEl.clientWidth * 2.6, so a longer quote resolves to a smaller size rather than a size that spills past the card's edge and gets visually clipped.

Why the industry badge always reflects the tab you just clicked, never the previous one

measureAndMove reads the newly selected tab's own getBoundingClientRect result on every switch, which is why the underline's translateX/scaleX pair always matches the label under the cursor instead of trailing behind the previous choice.

AI prompt for these sections

Each item ships with a matching prompt fragment that names its constants and its one computed layer, so pasting it into an AI coding tool regenerates the same file this post's demo runs.

What to build: a case-study section where a headline metric recomputes
itself from an easing function every animation frame and counts up from
zero, remeasuring its own digit-column width with canvas measureText the
instant an extra digit appears. Use generic placeholder project names —
no real company or client anywhere.

Running that fragment through an AI coding tool reproduces item 01's rolling counter — the same EASE_POW, the same digit-width remeasurement, none of the surrounding markup retyped by hand.

Accessibility across all nine

Every section checks window.matchMedia('(prefers-reduced-motion: reduce)') once on load and skips only the part of its code that would otherwise animate a value into view — the value itself, and the arithmetic that produced it, keeps computing whether or not that media query matches. The industry tabs additionally expose aria-selected on every button and follow the tab role pattern, keeping keyboard focus predictable as the underline moves from one tab to the next.

Questions worth answering before you build a case study page

Are these case study page design sections free to use in a commercial project?

Yes — nothing in the set loads a third-party library, a UI framework, or an external CDN script, so any of the nine can go straight into client work with nothing to credit or license.

Why does the story grid sometimes barely reshuffle between two datasets?

Only because both datasets produce similar normalized scores for that run — item 02's grid reads weighted fresh from whichever numbers are active, and a dataset with one clear standout is what actually pushes a tile into a bigger span.

Is browsing case study section ui design examples online the same as what's in this zip?

Not quite — a gallery of screenshots freezes at the moment they were captured, while the plain index.html files here keep recomputing their metric, their score, and their ratio every time the page opens.

Sources: MDN — IntersectionObserver API · MDN — Auto-placement in grid layout · MDN — the tab role · MDN — prefers-reduced-motion

The same measured-width-into-loop-duration idea behind item 08 shows up again in a set of nine sections built around introducing a team, and the FLIP-driven reorder behind item 07 plays a related role in a project showcase built from nine section templates instead of nine standalone widgets — one post recomputes what a single case study section shows, the other recomputes how a whole page of sections introduces a team or a body of work.

Enter the archive password

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