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

9 Project Showcase Section Templates — Copy-Paste

A project showcase section template bundles the blocks a portfolio page runs through — hero, grid, case study, contact form — and one of the nine below moves

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

The order below follows how a hiring manager or a client actually scrolls a portfolio, which matters more in project showcase section design than how flashy each piece looks. A hero says who you are in one screen, a project grid sorts the work by category, a case study block backs one project with real numbers, a skills row lists what you can build with, a timeline shows how you got here, a quote section borrows someone else's voice to vouch for you, a contact form removes the excuse not to reach out, a resume CTA hands over the one file a recruiter actually wants, and a footer keeps the visit going past the last section. Every circle, chip, and quote mark below is drawn purely from this site's $subject-ink, $subject-blue, and $subject-cream tokens — there's no real photo or logo file anywhere in these nine, so dropping one in later is a straight image swap that leaves the SCSS and JS untouched.

01Intro hero

Circles on independent loops

Three background circles drift on independent loops — different sizes, directions, and delays — while a rotated-square wordmark placeholder, headline, subhead, and two CTAs hold still in front of them.

Intro hero
Open in a new tab

01 · Intro hero — Measured area 31.004% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

transformopacityaria-pressed

A toggle, not a hover swap

The right-hand "get proposals" button is a real toggle: every click actually flips its aria-pressed attribute and scales its icon up to 1.2× — it isn't just a hover color swap.

.ih__orb--1 { animation: ih-float-a 2s ease-in-out infinite; }
@keyframes ih-float-a {
  0%, 100% { transform: translate(0, 0) scale(1); }
  50%      { transform: translate($sp-10, -$sp-8) scale(1.5); }
}
.ih__avail-btn[aria-pressed="true"] {
  background: rgba(255, 247, 230, .12);
  border-color: $subject-cream;
}
.ih__avail-btn[aria-pressed="true"] .ih__avail-ic {
  opacity: 1;
  color: $color;
  transform: scale(1.2);
}

02Project grid

A portfolio project page template that measures

Clicking a category chip (all, web, app, design) doesn't just recolor a tab — it re-measures that tab's actual rendered box with getBoundingClientRect and slides a solid indicator under it using translateX plus an inline width, so nothing here depends on a fixed pixel guess.

Project grid
Open in a new tab

02 · Project grid — Measured area 14.028% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

getBoundingClientRecttranslateXclick

What happens to the other cards

Cards outside the chosen category drop to 18% opacity and shrink slightly at the same time.

function moveIndicator(tab) {
  var wrapBox = tab.parentElement.getBoundingClientRect();
  var box = tab.getBoundingClientRect();
  indicator.style.width = box.width + 'px';
  indicator.style.transform = 'translateX(' + (box.left - wrapBox.left) + 'px)';
}
tabs.forEach(function (tab) {
  tab.addEventListener('click', function () {
    root.classList.remove('is-demo');
    tabs.forEach(function (t) { t.setAttribute('aria-selected', 'false'); });
    tab.setAttribute('aria-selected', 'true');
    moveIndicator(tab);
    var cat = tab.getAttribute('data-cat');
    cards.forEach(function (card) {
      var show = cat === 'all' || card.getAttribute('data-cat') === cat;
      card.classList.toggle('is-hidden', !show);
    });
  });
});

03Case study block

Expanding to real numbers

Pressing "See results" expands a hidden panel and, at the same moment, starts a requestAnimationFrame loop that carries three numbers — conversion rate, build time, traffic — from zero up to their real targets of 38%, 6 weeks, and 210% over 900ms on an eased curve.

Case study block
Open in a new tab

03 · Case study block — Measured area 0.461% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFramemax-heightclick

A bar that scans regardless

A thick accent bar above the card keeps scanning left to right the whole time, whether or not anyone has clicked yet.

function countUp() {
  if (hasCounted) return;
  hasCounted = true;
  var startTime = performance.now();
  var duration = 900;
  function easeOut(t) { return 1 - Math.pow(1 - t, 3); }
  function tick(now) {
    var t = Math.min(1, (now - startTime) / duration);
    var e = easeOut(t);
    nums.forEach(function (el) {
      var target = Number(el.getAttribute('data-target'));
      var suffix = el.getAttribute('data-suffix');
      el.textContent = Math.round(target * e) + suffix;
    });
    if (t < 1) requestAnimationFrame(tick);
  }
  requestAnimationFrame(tick);
}

04Skills stack

Eight chips in an unbroken loop

Eight shape-drawn skill chips — four distinct marks doubled end to end — scroll left in an unbroken loop, and resting the pointer on the strip or tapping the button beside it freezes the whole track mid-scroll.

Skills stack
Open in a new tab

04 · Skills stack — Measured area 8.058% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateX(-50%)animation-play-statepointerenter

Why the seam never jumps

The seam between the two copies never jumps, because the space between chips comes from margin-right on each one instead of a track-level gap.

// no gap on the track itself — each chip carries margin-right instead
.sk__track {
  display: flex;
  width: max-content;
  animation: sk-scroll 13s linear infinite;
}
.sk.is-paused .sk__track,
.sk__viewport:hover .sk__track { animation-play-state: paused; }
.sk__chip {
  flex: 0 0 auto;
  width: 68px; height: 32px;
  margin-right: $sp-5;
}

05Experience timeline

Filling the line to a stop

Four stops sit on a vertical line, and clicking any of them fills the track up to that point with a real scaleY transform sized to the stop's position out of four, then swaps the paragraph above to that stop's own sentence.

Experience timeline
Open in a new tab

05 · Experience timeline — Measured area 0.51% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

scaleYclickaria-current

A real aria-current, not a class

The active stop also updates a genuine aria-current="step" attribute rather than only a CSS class.

stops.forEach(function (btn) {
  btn.addEventListener('click', function () {
    root.classList.remove('is-demo');
    var i = Number(btn.getAttribute('data-i'));
    root.setAttribute('data-active', String(i));
    root.querySelector('.et__fill').style.transform = 'scaleY(' + ((i + 1) / stops.length) + ')';
    stops.forEach(function (s) { s.removeAttribute('aria-current'); });
    btn.setAttribute('aria-current', 'step');
    desc.textContent = careerNotes[i];
  });
});

06Quote testimonial

Three cards, stacked and staggered

Three testimonial cards sit stacked with a slight rotate-and-offset stagger, and clicking "Next review" doesn't cross-fade anything — it actually reorders the underlying array so the front card becomes the back one, card by card, in a real cycle.

Quote testimonial
Open in a new tab

06 · Quote testimonial — Measured area 0.188% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

transformclickaria-live

Reordering, not cross-fading

A large quotation mark on the front card keeps rotating and scaling on its own the whole time, independent of whichever card currently sits on top.

function render() {
  cards.forEach(function (card, i) { card.setAttribute('data-order', String(i)); });
  status.textContent = cards[0].querySelector('.pq__who').textContent + ' review now showing';
}
next.addEventListener('click', function () {
  root.classList.remove('is-demo');
  cards.push(cards.shift());
  render();
});

07Contact form

A regex, not a pretend check

Submitting the form with a name and email actually runs a regex check instead of pretending to; a blank name field shakes side to side, calls focus({ preventScroll: true }) on itself, and reports the problem through a status line — a malformed email address gets the same treatment on its own field.

Contact form
Open in a new tab

07 · Contact form — Measured area 0.917% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password

focusstroke-dashoffsetsubmit

What a failing field does

Only once both fields pass does a checkmark trace itself onto the submit button through stroke-dashoffset.

function shakeField(el) {
  el.classList.remove('is-shake');
  void el.offsetWidth;
  el.classList.add('is-shake');
}
form.addEventListener('submit', function (e) {
  e.preventDefault();
  var hasName = name.value.trim().length > 0;
  var isEmailValid = /^\S+@\S+\.\S+$/.test(email.value);
  if (!hasName) {
    shakeField(name);
    name.focus({ preventScroll: true });
    status.textContent = 'Please enter your name.';
    return;
  }
  if (!isEmailValid) {
    shakeField(email);
    email.focus({ preventScroll: true });
    status.textContent = 'Please enter a valid email address.';
    return;
  }
  form.classList.add('is-done');
  status.textContent = 'Your message has been sent.';
});

08Resume CTA

Progress, then a checkmark

Clicking "Download PDF" fills an inner progress layer across the whole button with scaleX over 900ms, then swaps the bouncing arrow icon for a check drawn with stroke-dashoffset, and 1,600ms after that it quietly resets itself back to idle.

Resume CTA
Open in a new tab

08 · Resume CTA — Measured area 0.111% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

scaleXsetTimeoutstroke-dashoffset

The arrow only bounces when idle

The arrow only bounces while nothing is happening — the instant loading starts, its own bounce animation pauses through animation-play-state.

btn.addEventListener('click', function () {
  if (isBusy) return;
  isBusy = true;
  btn.classList.add('is-loading');
  status.textContent = 'Preparing';
  setTimeout(function () {
    btn.classList.remove('is-loading');
    btn.classList.add('is-done');
    status.textContent = 'Download complete';
    setTimeout(function () {
      btn.classList.remove('is-done');
      status.textContent = '';
      isBusy = false;
    }, 1600);
  }, 900);
});

09Showcase footer

The same check, one field

A single email field feeds the same regex check as the contact form above: a passing address earns a stroke-dashoffset checkmark and a confirmation line; a failing one shakes the input instead.

Showcase footer
Open in a new tab

09 · Showcase footer — Measured area 0.583% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password

submitstroke-dashoffsetclick

Columns that never move

Underneath, three fixed link columns — showcase, contact, follow — stay in place no matter what the form above them is doing.

form.addEventListener('submit', function (e) {
  e.preventDefault();
  var ok = /^\S+@\S+\.\S+$/.test(input.value);
  if (ok) {
    form.classList.add('is-done');
    status.textContent = 'Subscribed — new projects will land in your inbox.';
  } else {
    input.classList.remove('is-shake');
    void input.offsetWidth;
    input.classList.add('is-shake');
    status.textContent = 'Please enter a valid email address.';
  }
});

Where it breaks — the trap

Four real breakages

Four real breakages turned up while this set was being built, and none of them came from picking the wrong easing curve.

A grid that grew a scrollbar

The first was the project grid (02). An early pass kept 44px thumbnails and generous tab padding, and the combined height of two card rows plus the tab strip pushed past the fixed 480×300 canvas, so the rendered frame grew a scrollbar. Shrinking the thumbnail to 30px and tightening the tab margin and grid gap pulled everything back inside the frame.

A ring too faint to measure

The second was the case study block (03). Its toggle button first wore a thin pulsing ring — a 2px border set 3px outside the button edge — and the frame-to-frame pixel difference stayed under the measurement threshold often enough that only 4 of 23 captured frames registered as moved. Swapping that thin ring for the bold scaleX accent-bar scan already proven elsewhere in this set pushed the same demo to 17 of 23 moved frames: a decoration only counts as real motion once it changes enough pixels to clear the threshold, not just enough to be visible to a person.

A stack shorter than its content

The third was the quote testimonial (06). Its card-stack container was set to 128px tall, shorter than what the actual card content needed — a large quote mark plus two lines of text plus a name plus padding — so the back card's text spilled past the front card's edge and visually crossed into the "Next review" button. Growing the stack to 164px and trimming the quote mark down to 42px cleared the overlap.

A form with nothing to measure

The fourth was the contact form (07). Because every one of its effects was gated behind a submit event, the very first render had nothing playing on a loop at all, so the automated capture measured zero animations and a 0.00% changed area. Giving the submit button the same idle glow pulse the rest of this set already carries gave the render something to actually measure. Every one of these four fixes already lives inside the copy you unlock with pcbj2jd9 — the smaller thumbnail, the accent-bar swap, the taller quote deck, and the glowing submit button sit in the vanilla and React folders exactly as described above, not merely written about here.

Accessibility (reduced-motion)

What reduced motion keeps

Every one of these nine checks prefers-reduced-motion: reduce before it plays anything, and each one only cuts the part that's pure decoration — the case study's scanning accent bar and the resume CTA's bouncing arrow both stop moving, while the count-up numbers still land on 38%, 6 weeks, and 210% because those digits carry information rather than flourish.

Tabs, steps, and measured math

The project grid (02) tracks its active category with role="tablist" and a live aria-selected, and its indicator math — read straight from MDN's getBoundingClientRect() reference — is what lets it survive a longer tab label or a swapped font without drifting off target.

Status regions and focus

The experience timeline (05) mirrors that pattern with aria-current="step" on whichever stop is active, the quote testimonial (06) reads out whose review just moved to the front through a visually hidden role="status" region, and the resume CTA (08) plus the contact form (07) each report their own progress with role="status" and aria-live="polite" — the contact form also moves real keyboard focus onto whichever field failed validation with focus({ preventScroll: true }), so the page itself stays put while only the focus ring jumps. Eight more layout sections wait in the template category, and the about page covers who's behind these demos and why every one ships as a working zip instead of a screenshot.

FAQ

Can I swap real project photos into these project showcase examples?

Yes, and that's the point of building them as shapes in the first place — the hero's wordmark, the grid's four thumbnails, and the skills stack's eight chips are all plain divs and pseudo-elements colored with this site's own $subject-ink, $subject-blue, and $subject-cream tokens, not image files. Replacing any of them with a real <img> or a logo asset is a straight swap; nothing in the layout math or the click handlers depends on what's actually inside those shapes.

Does the React version behave any differently from the plain HTML and SCSS one?

No — the behavior is identical, only where the state lives changes. Which tab is active, which testimonial card is on top, and which timeline stop is selected all move into useState instead of DOM classes and attributes, the project grid's indicator math runs inside a useEffect that calls getBoundingClientRect() on mount and again on every click, and the three SCSS variables (duration, easing, color) arrive as props that get forwarded onto the section as CSS custom properties.

Do the contact form and resume CTA actually send an email or hand over a file?

The validation, the focus jump, the progress fill, and the checkmark are all real state changes, not a canned animation loop — but neither demo is wired to a server, so submitting the contact form doesn't send mail and clicking the resume CTA doesn't hand over an actual PDF. Wiring either one up for real use means adding an API call inside the form's submit handler or pointing the CTA at a real file URL once its progress finishes; the validation and animation logic underneath needs no changes at all.

Enter the archive password

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