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

9 Team Page Design Patterns (Copy-Paste)

Nine team-page sections that compute their layout: columns balance by headcount, bios get read-more only past overflow, and a logo strip drifts at one speed no

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

Every section below replays a fixed scenario off a seeded generator and a frozen clock, never Math.random() or the real current time, so the same headcount, the same bio length, and the same local-time offset land in the same place on every load. A real interaction — a resize, a tab click, a hover on the bio card — stops that replay immediately and hands control to whoever is looking at the page; prefers-reduced-motion keeps every one of those calculations running while only dropping the transition that would otherwise animate the result into view.

Anatomy of a team page — what nine sections compute

Nothing here decides its layout by eyeballing a fixed number of columns or a fixed line count. Three small computations repeat across the nine sections in different shapes, and once you can name them, every item below reads as a variation on the same three ideas.

Why the entrance budget divides by headcount, not multiplies by index

A hero row that multiplies each photo's index by a fixed delay keeps growing its own total reveal time as the team grows; dividing a fixed budget by the current headcount instead keeps the whole stagger finished inside the same window whether four people or twelve are listed.

Why a grid remainder decides which row gets centered

Taking the headcount modulo the column count tells a grid exactly how many cards land in the last row before that row ever renders, and only a remainder of one or two is worth centering — a full last row needs no special case at all.

One filtered count feeds two places at once

Filtering the member array by a role value produces both the visible list for a tab and the live number printed on that tab's own label, so the count in the label can never drift out of sync with what actually renders underneath it.

Code per section

Paste any item's prompt from the zip into an AI coding tool and it rebuilds the same section — the block under each item below is trimmed straight out of that item's index.html, the same file the password-protected download ships alongside a matching React version.

01Team hero — a photo row whose entrance delay is computed from headcount

Why the delay step divides the same 480ms budget by headcount instead of scaling with it

An IntersectionObserver marks the single instant the section enters view, and only then does dividing a fixed entrance budget by the current headcount produce the per-photo delay step used below.

Team hero — a photo row whose entrance delay is computed from headcount
Open in a new tab

01 · Team hero — a photo row whose entrance delay is computed from headcount — Measured area 11.245% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

IntersectionObserverCSS custom propertiesprefers-reduced-motion

What the numbers say

Cumulative area lands at 11.24% (max frame 8.51%, mean intensity 54.2) across 12 of 23 frame-to-frame comparisons as the scripted scenario cycles four headcounts.

var ENTRANCE_MS = 480;   // entrance budget — fixed no matter the headcount
var counts = [5, 7, 4, 6];
var idx = 0;

// step math — dividing the budget by this round's headcount gives step,
// and index modulo headcount recompresses the spacing every cycle
// (the same formula gets reused whenever headcount changes)
function layout(count) {
  var step = ENTRANCE_MS / count;
  avatars.forEach(function (el, i) {
    var visible = i < count;
    el.style.display = visible ? '' : 'none';
    if (visible) el.style.setProperty('--delay', ((i % count) * step).toFixed(1) + 'ms');
  });
  chip.textContent = 'n=' + count + ' · step ' + Math.round(step) + 'ms';
  row.classList.remove('is-in');
  void row.offsetWidth;   // forced reflow — restarts the transition
  row.classList.add('is-in');
}

02Member grid — computing the last row's count to avoid an orphan card

Why the remainder only breaks into its own row at one or two members

A ResizeObserver floor-divides the measured container width by a minimum card width to get the column count, and headcount modulo that column count decides how many cards spill into the final row before a --cols shrink ever centers it.

Member grid — computing the last row's count to avoid an orphan card
Open in a new tab

02 · Member grid — computing the last row's count to avoid an orphan card — Measured area 29.079% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

CSS GridResizeObserverCSS custom properties

What the numbers say

Cumulative area comes out second-widest of the nine at 29.08% (max frame 11.50%), the lowest mean intensity of the set at 53.6, over 8 of 23 frames as the scenario cycles four headcounts against the same column math.

var cols = 4;   // default — ResizeObserver recomputes this from the measured width
var MIN_CARD = 76;

function computeCols(width) {
  var c = Math.floor(width / MIN_CARD);
  return Math.max(2, Math.min(5, c || 4));
}

function layout(count) {
  var remainder = count % cols;
  var tailCount = (remainder === 1 || remainder === 2) ? remainder : 0;
  var headCount = count - tailCount;
  head.style.setProperty('--cols', String(cols));
  head.innerHTML = '';
  tail.innerHTML = '';
  for (var i = 0; i < headCount; i++) head.appendChild(card(i));
  for (var j = 0; j < tailCount; j++) tail.appendChild(card(headCount + j));
  tail.style.display = tailCount > 0 ? 'flex' : 'none';
  chip.textContent = 'n=' + count + ' · cols ' + cols + ' · remainder ' + tailCount;
  grid.classList.remove('is-in');
  void grid.offsetWidth;
  grid.classList.add('is-in');
}

03Role tabs — a switch that computes the filtered count into its own label

Why the tab count is recomputed by filtering, not stored on each member

Filtering the roster array by a role value on every render is what keeps a label like "Design (4)" honest, and the same selected tab's getBoundingClientRect supplies the coordinates the underline below reads directly.

Role tabs — a switch that computes the filtered count into its own label
Open in a new tab

03 · Role tabs — a switch that computes the filtered count into its own label — Measured area 2.572% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password

filtergetBoundingClientRecttransform: translateX

What the numbers say

Cumulative area sits low at 2.57% (max frame 1.79%) but mean intensity is the second-highest of the nine at 117.4, moving on 7 of 23 frames as the underline snaps between tabs of unequal width.

// count math — the length of the array filtered by role gets attached to each tab label
KEYS.forEach(function (key) {
  var span = document.getElementById('cnt-' + key);
  if (!span) return;
  var n = key === 'all' ? members.length : members.filter(function (m) { return m.role === key; }).length;
  span.textContent = String(n);
});

function moveIndicator(tab) {
  var barRect = bar.getBoundingClientRect();
  var tabRect = tab.getBoundingClientRect();
  var ix = tabRect.left - barRect.left;
  var iw = tabRect.width;
  indicator.style.setProperty('--ix', ix.toFixed(1) + 'px');
  indicator.style.setProperty('--iw', iw.toFixed(1) + 'px');
}

04Profile detail — a card that measures bio height to decide whether read-more even appears

Why the height comparison runs twice, clamped then unclamped

Comparing the bio's line-clamped scrollHeight against its released scrollHeight before anything renders is what decides whether a read-more button shows up at all — a bio short enough to fit never gets one.

Profile detail — a card that measures bio height to decide whether read-more even appears
Open in a new tab

04 · Profile detail — a card that measures bio height to decide whether read-more even appears — Measured area 17.345% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

scrollHeightclassList.togglearia-expanded

What the numbers say

Cumulative area and max frame area land on the same figure, 17.34%, meaning nearly the whole visible change happens inside a single frame, over just 6 of 23 comparisons — the lowest frame count tied with item 07.

var card = wrap.querySelector('.detail__card');
var LINES = [3, 2];   // variant 04: clamp lines 3 to 2
var lineIdx = 0;

function measure(lineClamp) {
  bio.style.setProperty('--clamp', String(lineClamp));
  bio.classList.add('is-clamped');
  var clampedHeight = bio.scrollHeight;
  bio.classList.remove('is-clamped');
  var fullHeight = bio.scrollHeight;
  bio.classList.add('is-clamped');
  return fullHeight > clampedHeight + 1;
}

function apply(lineClamp) {
  var overflow = measure(lineClamp);
  more.style.display = overflow ? '' : 'none';
  more.setAttribute('aria-expanded', 'false');
  bio.classList.remove('is-open');
  chip.textContent = 'clamp ' + lineClamp + ' lines · ' + (overflow ? 'overflow O' : 'overflow X');
  if (card) card.classList.toggle('is-alt');
}

05Team stat row — a count-up that runs elapsed-time ratio through an easing function

Why the eased ratio, not the raw elapsed time, drives each digit

Every requestAnimationFrame tick divides how much time has passed by a fixed duration into a 0-to-1 ratio, and running that ratio through an easing curve before multiplying it by the target number is what keeps the climb from feeling linear and mechanical.

Team stat row — a count-up that runs elapsed-time ratio through an easing function
Open in a new tab

05 · Team stat row — a count-up that runs elapsed-time ratio through an easing function — Measured area 1.517% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFrameeasing functionperformance.now

What the numbers say

Mean intensity peaks highest of the nine at 160.4 with zero declared CSS animations at all — every one of the 20 moving frames out of 23 is a plain text redraw computed frame by frame.

function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }

function runOnce(ms) {
  if (raf !== null) cancelAnimationFrame(raf);
  var start = performance.now();
  var targets = nums.map(function (el) { return parseInt(el.getAttribute('data-target'), 10) || 0; });
  function step(now) {
    var t = Math.min(1, (now - start) / ms);
    var eased = easeOutCubic(t);
    nums.forEach(function (el, i) {
      el.textContent = String(Math.round(targets[i] * eased));
    });
    if (t < 1) {
      raf = requestAnimationFrame(step);
    } else {
      raf = null;
    }
  }
  nums.forEach(function (el) { el.textContent = '0'; });
  raf = requestAnimationFrame(step);
}

06Partner logo bar — a scroll that reverse-computes its speed from the measured track width

Why the duration is solved backward from a fixed pixel speed instead of set directly

Measuring one logo set's own rendered width and dividing a target pixels-per-second speed into it is what produces the exact animation-duration that set needs, so the felt speed stays identical however many logos are in the row.

Partner logo bar — a scroll that reverse-computes its speed from the measured track width
Open in a new tab

06 · Partner logo bar — a scroll that reverse-computes its speed from the measured track width — Measured area 7.333% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

getBoundingClientRectanimation-durationCSS custom properties

What the numbers say

Item 06 is the only one of the nine moving on all 23 of 23 frame-to-frame comparisons, despite a modest cumulative area of 7.33% (max frame 1.46%) — a slow, continuous drift rather than a sudden change.

var SPEEDS = [60, 30];   // variant 06: px/s
var speedIdx = 0;

function apply(speed) {
  var setWidth = setA.getBoundingClientRect().width;
  if (setWidth <= 0) return;
  var durationS = setWidth / speed;
  track.style.setProperty('--dur', durationS.toFixed(2) + 's');
  chip.textContent = 'speed ' + speed + 'px/s · ' + durationS.toFixed(1) + 's/lap';
}

07Team quote card — reverse-computing line count to size the quote itself

Why the font size is solved backward from a fixed row height instead of picked by length

Dividing a quote's scrollHeight by the line-height read through getComputedStyle gives the actual rendered line count, and only then can a target row height divided by that count decide how large the font is allowed to be.

Team quote card — reverse-computing line count to size the quote itself
Open in a new tab

07 · Team quote card — reverse-computing line count to size the quote itself — Measured area 19.911% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

getComputedStyleline-heightCSS custom properties

What the numbers say

Cumulative area 19.91% sits close to its own max frame area of 18.25%, mean intensity 69.0, over the same low 6-of-23 frame count as item 04 — most of the visible change lands in one swap.

var TARGET_H = 64;   // target row height (px) — the quote has to fit inside this
var MIN_FONT = 12;
var MAX_FONT = 20;

function lineHeightRatio() {
  var cs = getComputedStyle(textEl);
  var lh = parseFloat(cs.lineHeight);
  var fs = parseFloat(cs.fontSize) || 16;
  if (!lh || isNaN(lh)) return 1.4;
  return lh / fs;
}

function fitFontSize(length) {
  var ratio = lineHeightRatio();
  var estLines = length > 40 ? 3 : length > 18 ? 2 : 1;
  var maxByHeight = TARGET_H / (estLines * ratio);
  var size = Math.max(MIN_FONT, Math.min(MAX_FONT, Math.floor(maxByHeight)));
  return size;
}

08Apply CTA — a badge that computes days left until deadline on every load

Why the countdown is computed from a fixed epoch, never from the visitor's clock

Subtracting a fixed reference instant from a role's deadline and dividing that gap by a day's worth of milliseconds, rounded up, is what the badge's D-day number actually is — never the visitor's own clock, or the same deadline would show a different number to every reader.

Apply CTA — a badge that computes days left until deadline on every load
Open in a new tab

08 · Apply CTA — a badge that computes days left until deadline on every load — Measured area 37.419% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

DateMath.ceilaria-live

What the numbers say

Cumulative area is the widest of the nine at 37.42%, matching its own max frame area of 36.84% almost exactly — the whole badge swap lands inside a single frame, over 10 of 23 comparisons, with zero declared CSS animations.

var EPOCH = new Date('2026-01-01T00:00:00Z').getTime();   // fixed reference instant
var DAY_MS = 86400000;
var URGENT_DAYS = 3;

var ROLES = [
  { name: 'Frontend Engineer', deadline: '2026-01-13T00:00:00Z' },
  { name: 'Product Designer', deadline: '2026-01-03T00:00:00Z' },
  { name: 'Backend Engineer', deadline: '2026-01-21T00:00:00Z' }
];
var idx = 0;

function daysLeft(deadlineIso) {
  var deadline = new Date(deadlineIso).getTime();
  return Math.ceil((deadline - EPOCH) / DAY_MS);
}

function apply(i) {
  var r = ROLES[i];
  var d = daysLeft(r.deadline);
  role.textContent = r.name;
  daysEl.textContent = 'D-' + d;
  var urgent = d <= URGENT_DAYS;
  card.classList.toggle('is-urgent', urgent);
  chip.textContent = 'baseline 2026-01-01 · ' + (urgent ? 'Urgent' : 'Relaxed');
}

09Team footer — a row computing each member's local time from their time zone

Why Intl.DateTimeFormat decides quiet hours, not a stored UTC offset

Feeding each member's stored IANA time zone string into Intl.DateTimeFormat's timeZone option computes that member's own local hour directly from a single simulated instant, and only that computed hour decides whether a quiet marker gets layered on.

Team footer — a row computing each member's local time from their time zone
Open in a new tab

09 · Team footer — a row computing each member's local time from their time zone — Measured area 1.403% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

Intl.DateTimeFormatsetIntervaltimeZone

What the numbers say

Cumulative area is the smallest of the nine at 1.40% (max frame 0.95%), over 8 of 23 frames with zero declared CSS animations — every member row is a plain text swap, not a transition.

function hourIn(zone, date) {
  var s = new Intl.DateTimeFormat('en-GB', { timeZone: zone, hour: 'numeric', hourCycle: 'h23' }).format(date);
  return parseInt(s, 10);
}

var elapsedMin = 0;

function render() {
  var now = new Date(EPOCH + elapsedMin * 60000);
  MEMBERS.forEach(function (m) {
    var label = new Intl.DateTimeFormat('en-GB', { timeZone: m.zone, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(now);
    m.timeEl.textContent = label;
    m.timeEl.setAttribute('datetime', now.toISOString());
    var hour = hourIn(m.zone, now);
    var quiet = hour < 7 || hour >= 22;
    m.cell.classList.toggle('is-quiet', quiet);
  });
  chip.textContent = 'elapsed ' + elapsedMin + ' min';
}

Where data reshapes layout — headcount, text length, time zones

None of these nine team page design templates has a fixed pixel layout waiting for content to fill it; the content itself decides the numbers a stylesheet ends up reading.

Why item 02's --cols only shrinks a tail row for a remainder of one or two

A remainder of zero already fills every column evenly, and a remainder of three or more on a five-column grid still reads as a normal row — only one or two leftover cards look sparse enough to be worth centering with a shrunk --cols, which is exactly why the check exists as a narrow band, not a blanket rule for any leftover count.

Why item 09 never calls Date.now()

A footer built from the visitor's own clock would show a different set of local times on every load, which breaks the whole point of a reproducible team page design examples set meant to be screenshotted and verified against fixed numbers — item 09 instead advances a simulated clock from a frozen epoch by a fixed number of minutes per tick.

Where these nine actually get used

A team roster under a job posting, the people section of an about page, and the member list on an agency's portfolio page are the three screens that reach for this set most often. Item 02's member grid fits a hiring page where headcount keeps changing, and item 04's profile detail fits an about page where each teammate's bio should expand on demand.

For a distributed team spread across time zones, item 09's footer tells visitors whether now is even a reasonable time to reach out. All three screens share one thing: the headcount isn't fixed, so a layout that never hardcodes the card count fits them without extra work.

Where it breaks — odd headcounts, long bios, the time zone API

A computed layer only pays off if the edge cases that feed it are handled on purpose, not by accident.

Why an odd headcount without the remainder math strands a card

Render a five-person team on a grid that only ever assumes an even split and the fifth card either overflows into a half-empty row or gets silently dropped by a naive nth-child rule — item 02's remainder check exists specifically so an odd headcount never produces either failure.

Why item 04's 1px tolerance matters more than it looks

Comparing scrollHeight values without a small tolerance can flag a bio as overflowing purely from a sub-pixel rounding difference between the clamped and unclamped measurement, which is why the real check is fullHeight > clampedHeight + 1 rather than a bare inequality — the full working comparison, not just this rounded-down summary, sits behind ucthsz2g in the zip linked from this post.

Why Intl.DateTimeFormat's timeZone beats a manual UTC offset

Hand-rolling each member's local hour from a stored UTC offset silently breaks the moment daylight saving shifts that region's clock, while Intl.DateTimeFormat's timeZone option reads the browser's own maintained rules and needs no manual correction at all.

Slow the count-up and the logo scroll, and accessibility comes along

Variant What changes What it trades off
Stat count-up duration Item 05's duration from 1600 to 800 (ms) 1600ms keeps the climbing digits easy to follow but feels dragged out to a fast scroller; 800ms feels immediate, but a large target number blurs past too fast to read mid-count
Partner logo drift speed Item 06's speed from 60 to 30 (px/s) 60px/s reads as a light sweep across the logos, easy to miss any single one; 30px/s lets each logo register, at the cost of the whole strip feeling sluggish
Bio read-more clamp Item 04's line count from 3 to 2 Three lines carries enough bio context that fewer visitors bother clicking read-more, but the card runs tall; two lines keeps every card the same height, at the cost of a read-more button on almost every card

What reduced motion turns off, item by item

prefers-reduced-motion: reduce removes travel, not computation, across all nine: item 01's stagger and item 02's grid re-flow both jump straight to their end state while the headcount math and remainder math underneath still run in full. Item 05's digits stop climbing frame by frame and simply show the target number, but the target itself is still read from the same array; item 09's quiet-hour marker keeps recomputing on every simulated tick even though the interval driving the tick itself is what a reduced-motion build would slow down first.

Three questions that keep coming up once this ships

Is a team page design examples set like this free to reuse commercially?

Yes — every section here is vanilla HTML, CSS, and JavaScript with no library, no framework, and no external CDN call anywhere, so nothing about reusing the code depends on a license from a third party.

Why does the member grid sometimes leave one card looking alone on the last row?

Only because the remainder math described above hasn't run yet on that build — once headcount modulo column count is checked and a remainder of one or two shrinks that row's --cols, an odd headcount never strands a single card again.

Does turning on reduced motion break the apply-deadline countdown badge?

No — the D-day subtraction against the fixed epoch keeps running exactly the same either way; reduced motion only removes the badge's color-flip transition, not the days-left number itself.

Sources: MDN — Auto-placement in grid layout · MDN — Element.scrollHeight · MDN — Intl.DateTimeFormat · MDN — prefers-reduced-motion

The same overflow-before-render idea behind item 04 shows up again in a profile card design set that runs its own flip and tilt math before deciding what to show on the back face, and the remainder logic item 02 leans on to avoid an orphan card echoes the same kind of guard rail as a dropzone that sizes its own preview from a measured aspect ratio rather than a fixed thumbnail box — both compute a number first and let the layout follow it, not the other way around.

Enter the archive password

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