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
- 01 Intro hero
- 02 Project grid
- 03 Case study block
- 04 Skills stack
- 05 Experience timeline
- 06 Quote testimonial
- 07 Contact form
- 08 Resume CTA
- 09 Showcase footer
The order below follows how a hiring manager or a client actually scrolls a portfolio, not 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
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. 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
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. Cards outside the chosen category drop to 18% opacity and shrink slightly at the same time.
function 인디케이터이동(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');
인디케이터이동(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
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. A thick accent bar above the card keeps scanning left to right the whole time, whether or not anyone has clicked yet.
function 카운트업() {
if (재생됨) return;
재생됨 = true;
var 시작 = performance.now();
var 길이 = 900;
function easeOut(t) { return 1 - Math.pow(1 - t, 3); }
function tick(now) {
var t = Math.min(1, (now - 시작) / 길이);
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 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. 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
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. 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 = 설명[i];
});
});
06Quote testimonial
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. 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 + ' 후기를 보는 중';
}
next.addEventListener('click', function () {
root.classList.remove('is-demo');
cards.push(cards.shift());
render();
});
07Contact form
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. Only once both fields pass does a checkmark trace itself onto the submit button through stroke-dashoffset.
function 흔들기(el) {
el.classList.remove('is-shake');
void el.offsetWidth;
el.classList.add('is-shake');
}
form.addEventListener('submit', function (e) {
e.preventDefault();
var 이름있음 = name.value.trim().length > 0;
var 이메일유효 = /^\S+@\S+\.\S+$/.test(email.value);
if (!이름있음) {
흔들기(name);
name.focus({ preventScroll: true });
status.textContent = '이름을 입력해 주세요.';
return;
}
if (!이메일유효) {
흔들기(email);
email.focus({ preventScroll: true });
status.textContent = '올바른 이메일을 입력해 주세요.';
return;
}
form.classList.add('is-done');
status.textContent = '메시지를 보냈습니다.';
});
08Resume CTA
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. 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 (진행중) return;
진행중 = true;
btn.classList.add('is-loading');
status.textContent = '준비 중';
setTimeout(function () {
btn.classList.remove('is-loading');
btn.classList.add('is-done');
status.textContent = '다운로드 완료';
setTimeout(function () {
btn.classList.remove('is-done');
status.textContent = '';
진행중 = false;
}, 1600);
}, 900);
});
09Showcase footer
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. 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 = '구독 완료 — 새 프로젝트를 메일로 보내드립니다.';
} else {
input.classList.remove('is-shake');
void input.offsetWidth;
input.classList.add('is-shake');
status.textContent = '올바른 이메일을 입력해 주세요.';
}
});
Where it breaks — the trap
Four real breakages turned up while this set was being built, and none of them came from picking the wrong easing curve.
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.
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.
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.
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)
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. 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. 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 in real project photos and logos instead of the placeholder shapes?
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.