01Team hero — a photo row whose entrance delay is computed from headcount
IntersectionObserver marks the single moment the section enters view, and multiplying each photo's index by a fixed step into a --delay CSS variable staggers the reveal in order; taking the index modulo the team size recompresses the spacing so a bigger team never drags the total reveal out indefinitely.
Best forCareers page header, startup landing intro
IntersectionObserverCSS custom propertiesprefers-reduced-motion

<main class="stage">
<section class="hero" id="hero">
<p class="hero__hint" id="heroHint">The stagger interval compresses to fit the headcount, using only the moment the section enters the viewport as its reference.</p>
<div class="hero__row" id="heroRow" aria-label="Team photo row" role="list">
<div class="hero__avatar" role="listitem"><canvas class="hero__cv" width="48" height="48" aria-hidden="true"></canvas><span class="hero__tag">M1</span></div>
<div class="hero__avatar" role="listitem"><canvas class="hero__cv" width="48" height="48" aria-hidden="true"></canvas><span class="hero__tag">M2</span></div>
<div class="hero__avatar" role="listitem"><canvas class="hero__cv" width="48" height="48" aria-hidden="true"></canvas><span class="hero__tag">M3</span></div>
<div class="hero__avatar" role="listitem"><canvas class="hero__cv" width="48" height="48" aria-hidden="true"></canvas><span class="hero__tag">M4</span></div>
<div class="hero__avatar" role="listitem"><canvas class="hero__cv" width="48" height="48" aria-hidden="true"></canvas><span class="hero__tag">M5</span></div>
<div class="hero__avatar" role="listitem"><canvas class="hero__cv" width="48" height="48" aria-hidden="true"></canvas><span class="hero__tag">M6</span></div>
<div class="hero__avatar" role="listitem"><canvas class="hero__cv" width="48" height="48" aria-hidden="true"></canvas><span class="hero__tag">M7</span></div>
</div>
<p class="hero__meta"><span class="hero__chip" id="heroChip">n=5 · step 96ms</span></p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter; // 600ms — 등장 트랜지션
$easing: $ease-spring;
$color: $subject-blue-deep;
@include stage($stage-ink);
.hero {
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
gap: $sp-3;
align-items: center;
text-align: center;
}
.hero__hint {
margin: 0;
padding: 0 $sp-2;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: rgba($subject-cream, .72);
}
.hero__row {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: $sp-2;
width: 100%;
}
.hero__avatar {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
opacity: 0;
transform: translateY($sp-4);
transition: opacity $dur-quick $ease-out, transform $duration $easing;
transition-delay: var(--delay, 0ms);
}
.hero__row.is-in .hero__avatar {
opacity: 1;
transform: translateY(0);
}
.hero__cv {
display: block;
width: 48px;
height: 48px;
border-radius: $r-pill;
box-shadow: $shadow-press;
}
.hero__tag {
font-weight: 500;
font-size: 9px;
color: rgba($subject-cream, .72);
}
.hero__meta {
margin: 0;
}
.hero__chip {
display: inline-flex;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $color;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
}
@media (max-width: 340px) {
.hero { max-width: 300px; gap: $sp-2; }
.hero__hint { font-size: 9px; }
.hero__row { gap: $sp-1; }
.hero__cv { width: 36px; height: 36px; }
.hero__tag { font-size: 8px; }
.hero__chip { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.hero__avatar {
transition: none;
opacity: 1;
transform: none;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hero {
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
text-align: center;
}
.hero__hint {
margin: 0;
padding: 0 8px;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: rgba(255, 247, 230, 0.72);
}
.hero__row {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
width: 100%;
}
.hero__avatar {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
opacity: 0;
transform: translateY(16px);
transition: opacity 220ms ease-out, transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
transition-delay: var(--delay, 0ms);
}
.hero__row.is-in .hero__avatar {
opacity: 1;
transform: translateY(0);
}
.hero__cv {
display: block;
width: 48px;
height: 48px;
border-radius: 999px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.hero__tag {
font-weight: 500;
font-size: 9px;
color: rgba(255, 247, 230, 0.72);
}
.hero__meta {
margin: 0;
}
.hero__chip {
display: inline-flex;
padding: 4px 12px;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
@media (max-width: 340px) {
.hero {
max-width: 300px;
gap: 8px;
}
.hero__hint {
font-size: 9px;
}
.hero__row {
gap: 4px;
}
.hero__cv {
width: 36px;
height: 36px;
}
.hero__tag {
font-size: 8px;
}
.hero__chip {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.hero__avatar {
transition: none;
opacity: 1;
transform: none;
}
}// 팀 히어로(01) — IntersectionObserver 로 섹션이 화면에 들어온 시점 하나만 기준을 잡고,
// 등장 예산(ENTRANCE_MS)을 그 순간의 인원수로 나눠 인덱스마다 곱하는 간격(step)을 매번 계산한다.
// 인원이 늘어도 등장 예산은 고정이라 총 등장 시간이 무한히 늘지 않는다(계산 한 겹).
// 자동 시연은 인원수를 5·7·4·6 으로 순환시켜 매번 다시 계산되는 것을 보여준다.
(function () {
var hero = document.getElementById('hero');
var row = document.getElementById('heroRow');
var chip = document.getElementById('heroChip');
if (!hero || !row || !chip) return;
var avatars = Array.prototype.slice.call(row.querySelectorAll('.hero__avatar'));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function mulberry32(a) {
return function () {
a |= 0; a = a + 0x6D2B79F5 | 0;
var t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
var HEXES = ['#9fc9ec', '#93d9c0', '#b9a0ff', '#f6c6d3'];
function drawAvatar(cv, seed, label) {
var ctx = cv.getContext('2d');
if (!ctx) return;
var rnd = mulberry32(seed);
var s = cv.width;
ctx.clearRect(0, 0, s, s);
ctx.save();
ctx.beginPath(); ctx.arc(s / 2, s / 2, s / 2, 0, Math.PI * 2); ctx.clip();
var cell = 8;
for (var y = 0; y < s; y += cell) {
for (var x = 0; x < s; x += cell) {
var v = rnd();
ctx.fillStyle = v > .5 ? HEXES[Math.floor(rnd() * HEXES.length)] : 'rgba(23,20,26,.10)';
ctx.fillRect(x, y, cell, cell);
}
}
ctx.restore();
ctx.fillStyle = 'rgba(23,20,26,.55)';
ctx.beginPath(); ctx.arc(s / 2, s / 2, s / 2, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#fff7e6';
ctx.font = '700 ' + Math.round(s * .38) + 'px -apple-system, "Segoe UI", sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(label, s / 2, s / 2 + 1);
}
avatars.forEach(function (el, i) {
var cv = el.querySelector('.hero__cv');
if (cv) drawAvatar(cv, 31 + i * 17, 'M' + (i + 1));
});
var ENTRANCE_MS = 480; // 등장 예산 — 인원수와 무관하게 고정
var counts = [5, 7, 4, 6];
var idx = 0;
// 등장 간격 계산 — 예산을 이번 인원수로 나눈 몫이 step, 인덱스를 인원수로 나눈 나머지로
// 매 순환마다 압축 폭을 다시 맞춘다(headcount 가 바뀌어도 같은 공식 재사용)
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; // 강제 리플로우 — 트랜지션 재시작
row.classList.add('is-in');
}
var fired = false;
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (e.isIntersecting && !fired) { fired = true; layout(counts[0]); }
});
}, { threshold: .3 });
io.observe(hero);
if (reduce) {
layout(counts[0]);
} else {
layout(counts[0]);
setInterval(function () {
idx = (idx + 1) % counts.length;
layout(counts[idx]);
}, 650);
}
})();// TeamHero — vanilla 판(run/338/demo/01-team-hero/index.html) 의 React 포트.
// IntersectionObserver 로 섹션이 화면에 들어온 시점 하나만 기준을 잡고, 등장 예산(ENTRANCE_MS)을
// 그 순간의 인원수로 나눠 인덱스마다 곱하는 간격(step)을 매번 계산한다(계산 한 겹). 자동 시연은
// 인원수를 5·7·4·6 으로 순환시켜 매번 다시 계산되는 것을 보여준다.
import { useEffect, useRef } from "react";
import styles from "./TeamHero.module.scss";
export type TeamHeroProps = {
duration?: string;
easing?: string;
color?: string;
};
const HINT = "섹션이 화면에 들어온 시점 하나만 기준으로 인원수에 맞춰 등장 간격을 압축합니다";
const ROW_LABEL = "팀원 사진 줄";
function mulberry32(a: number): () => number {
return function (): number {
a |= 0; a = a + 0x6d2b79f5 | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const HEXES = ["#9fc9ec", "#93d9c0", "#b9a0ff", "#f6c6d3"];
const ENTRANCE_MS = 480;
const COUNTS = [5, 7, 4, 6];
const MEMBER_MAX = 7;
function drawAvatar(cv: HTMLCanvasElement, seed: number, label: string): void {
const ctx = cv.getContext("2d");
if (!ctx) return;
const rnd = mulberry32(seed);
const s = cv.width;
ctx.clearRect(0, 0, s, s);
ctx.save();
ctx.beginPath();
ctx.arc(s / 2, s / 2, s / 2, 0, Math.PI * 2);
ctx.clip();
const cell = 8;
for (let y = 0; y < s; y += cell) {
for (let x = 0; x < s; x += cell) {
const v = rnd();
ctx.fillStyle = v > 0.5 ? HEXES[Math.floor(rnd() * HEXES.length)] : "rgba(23,20,26,.10)";
ctx.fillRect(x, y, cell, cell);
}
}
ctx.restore();
ctx.fillStyle = "rgba(23,20,26,.55)";
ctx.beginPath();
ctx.arc(s / 2, s / 2, s / 2, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#fff7e6";
ctx.font = "700 " + Math.round(s * 0.38) + 'px -apple-system, "Segoe UI", sans-serif';
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(label, s / 2, s / 2 + 1);
}
export default function TeamHero({
duration = "600ms",
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#0a4fe8"
}: TeamHeroProps) {
const heroRef = useRef<HTMLElement | null>(null);
const rowRef = useRef<HTMLDivElement | null>(null);
const chipRef = useRef<HTMLSpanElement | null>(null);
useEffect(() => {
const hero = heroRef.current;
const row = rowRef.current;
const chip = chipRef.current;
if (!hero || !row || !chip) return;
const avatars = Array.prototype.slice.call(row.querySelectorAll("." + styles.avatar)) as HTMLElement[];
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
avatars.forEach((el, i) => {
const cv = el.querySelector("." + styles.cv) as HTMLCanvasElement | null;
if (cv) drawAvatar(cv, 31 + i * 17, "M" + (i + 1));
});
function layout(count: number): void {
const step = ENTRANCE_MS / count;
avatars.forEach((el, i) => {
const 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(styles.rowIn);
void row!.offsetWidth;
row!.classList.add(styles.rowIn);
}
let fired = false;
const io = new IntersectionObserver((entries) => {
entries.forEach((e) => {
if (e.isIntersecting && !fired) { fired = true; layout(COUNTS[0]); }
});
}, { threshold: 0.3 });
io.observe(hero);
let idx = 0;
let timer = 0;
if (reduce) {
layout(COUNTS[0]);
} else {
layout(COUNTS[0]);
timer = window.setInterval(() => {
idx = (idx + 1) % COUNTS.length;
layout(COUNTS[idx]);
}, 650);
}
return () => {
io.disconnect();
if (timer) window.clearInterval(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const members = Array.from({ length: MEMBER_MAX }, (_, i) => i);
return (
<main className={styles.stage}>
<section className={styles.hero} ref={heroRef}>
<p className={styles.hint}>{HINT}</p>
<div className={styles.row} ref={rowRef} role="list" aria-label={ROW_LABEL}>
{members.map((i) => (
<div className={styles.avatar} role="listitem" key={i}>
<canvas className={styles.cv} width={48} height={48} aria-hidden="true" />
<span className={styles.tag}>{"M" + (i + 1)}</span>
</div>
))}
</div>
<p className={styles.meta}>
<span className={styles.chip} ref={chipRef} style={{ background: color }}>n=5 · step 96ms</span>
</p>
</section>
</main>
);
}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 · 9 Team Page Design Patterns (Copy-Paste)
The post this demo lives in
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, See all demos in this post
If you like this effect - similar demos
Landing sections06 Video card — a card that derives playback from viewport ratio and scrub position from scroll9 Case Study Page Design Sections (Copy-Paste)
Landing sections08 Scroll-reached CTA ribbonLanding Page Conversion: 9 Sections That Convert
Landing sections08 Wavy-edge footerWebsite Footer Design: 9 Copy-Paste Patterns