09Team grid expand
One of three side-by-side teammates widens while the role and follow button rise from below, and the other two narrow over the same beat so the total width never changes.
Best forThe team section of an about page, where several people share a row and one at a time gets the detail
flex-growtranslateYaria-expanded

<main class="stage">
<div class="tg is-demo">
<p class="tg__head">
<svg viewBox="0 0 256 256" width="15" height="15" aria-hidden="true"><g fill="currentColor"><path d="M168 144a40 40 0 1 1-40-40a40 40 0 0 1 40 40M64 56a32 32 0 1 0 32 32a32 32 0 0 0-32-32m128 0a32 32 0 1 0 32 32a32 32 0 0 0-32-32" opacity=".2"/><path d="M244.8 150.4a8 8 0 0 1-11.2-1.6A51.6 51.6 0 0 0 192 128a8 8 0 0 1 0-16a24 24 0 1 0-23.24-30a8 8 0 1 1-15.5-4A40 40 0 1 1 219 117.51a67.94 67.94 0 0 1 27.43 21.68a8 8 0 0 1-1.63 11.21M190.92 212a8 8 0 1 1-13.85 8a57 57 0 0 0-98.15 0a8 8 0 1 1-13.84-8a72.06 72.06 0 0 1 33.74-29.92a48 48 0 1 1 58.36 0A72.06 72.06 0 0 1 190.92 212M128 176a32 32 0 1 0-32-32a32 32 0 0 0 32 32m-56-56a8 8 0 0 0-8-8a24 24 0 1 1 23.24-30a8 8 0 1 0 15.5-4A40 40 0 1 0 37 117.51a67.94 67.94 0 0 0-27.4 21.68a8 8 0 1 0 12.8 9.61A51.6 51.6 0 0 1 64 128a8 8 0 0 0 8-8"/></g></svg>
Team members
</p>
<div class="tg__row">
<div class="tg__tile is-open">
<button class="tg__main" type="button" aria-expanded="true">
<span class="tg__avatar tg__avatar--a" aria-hidden="true">JH</span>
<span class="tg__name">Juha Kang</span>
<span class="tg__role">Team lead</span>
</button>
<button class="tg__follow" type="button" aria-pressed="false">
<span class="tg__f-off"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>Follow</span>
<span class="tg__f-on"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>Following</span>
</button>
</div>
<div class="tg__tile">
<button class="tg__main" type="button" aria-expanded="false">
<span class="tg__avatar tg__avatar--b" aria-hidden="true">ES</span>
<span class="tg__name">Eunseol Noh</span>
<span class="tg__role">Product designer</span>
</button>
<button class="tg__follow" type="button" aria-pressed="false">
<span class="tg__f-off"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>Follow</span>
<span class="tg__f-on"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>Following</span>
</button>
</div>
<div class="tg__tile">
<button class="tg__main" type="button" aria-expanded="false">
<span class="tg__avatar tg__avatar--c" aria-hidden="true">GO</span>
<span class="tg__name">Gaon Im</span>
<span class="tg__role">Front-end dev</span>
</button>
<button class="tg__follow" type="button" aria-pressed="false">
<span class="tg__f-off"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>Follow</span>
<span class="tg__f-on"><svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>Following</span>
</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 타일이 넓어지는 시간
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.tg { width: 320px; }
.tg__head {
display: flex;
align-items: center;
gap: $sp-2;
margin: 0 0 $sp-3;
font-weight: 700;
font-size: 13px;
line-height: 18px;
color: $subject-cream;
}
.tg__head svg { flex: none; color: rgba(255, 255, 255, .55); }
.tg__row { display: flex; gap: $sp-2; }
.tg__tile {
position: relative;
flex: 1 1 0;
min-width: 0;
height: 136px;
border-radius: $r-card;
background: rgba(255, 255, 255, .07);
overflow: hidden; // 접힌 타일에서 넘치는 글자는 잘린다
flex-grow: 1;
transition: flex-grow $duration $easing, background $duration $easing;
}
.tg__tile.is-open { flex-grow: 2.6; background: rgba(255, 255, 255, .15); }
.tg__main {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
padding: $sp-3 $sp-2 $sp-10;
border: 0;
background: transparent;
color: $subject-cream;
text-align: center;
cursor: pointer;
touch-action: manipulation;
}
.tg__main:focus-visible { outline: 2px solid $color; outline-offset: -2px; border-radius: $r-card; }
.tg__avatar {
display: flex;
align-items: center;
justify-content: center;
flex: none;
width: 36px;
height: 36px;
border-radius: 50%;
color: $subject-ink;
font-weight: 800;
font-size: 13px;
line-height: 16px;
}
.tg__avatar--a { background: linear-gradient(135deg, $subject-blue, $subject-lilac); }
.tg__avatar--b { background: linear-gradient(135deg, $subject-pink, $subject-lilac); }
.tg__avatar--c { background: linear-gradient(135deg, $subject-mint, $subject-sky); }
.tg__name {
max-width: 100%;
font-weight: 800;
font-size: 13px;
line-height: 17px;
color: $subject-cream;
white-space: nowrap;
}
.tg__role {
max-width: 100%;
font-weight: 600;
font-size: 11px;
line-height: 14px;
color: rgba(255, 255, 255, .62);
white-space: nowrap;
opacity: 0;
transform: translateY(8px);
transition: opacity $duration $easing, transform $duration $easing;
}
.tg__tile.is-open .tg__role { opacity: 1; transform: translateY(0); }
.tg__follow {
position: absolute;
left: $sp-2;
right: $sp-2;
bottom: $sp-3;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-1;
height: 28px;
border: 0;
border-radius: $r-control;
background: $color;
color: #fff;
font-weight: 700;
font-size: 12px;
line-height: 14px;
white-space: nowrap;
cursor: pointer;
touch-action: manipulation;
opacity: 0;
transform: translateY(8px);
pointer-events: none;
transition: opacity $duration $easing, transform $duration $easing, background $dur-quick $easing;
}
.tg__tile.is-open .tg__follow { opacity: 1; transform: translateY(0); pointer-events: auto; }
.tg__follow:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.tg__follow.is-on { background: rgba(255, 255, 255, .18); color: $subject-cream; }
.tg__f-off,
.tg__f-on { display: inline-flex; align-items: center; gap: $sp-1; }
.tg__f-on { display: none; }
.tg__follow.is-on .tg__f-off { display: none; }
.tg__follow.is-on .tg__f-on { display: inline-flex; }
// ── 자동 루프: 1번 → 2번 → 3번 순으로 flex-grow 가 커졌다 작아진다.
// 시차는 animation: 단축이 delay 를 0 으로 리셋하므로 낱개 속성 + animation-delay 로 준다.
// 마지막 타일도 87%(1744ms) 안에 1 로 돌아오고 끝까지 그대로 → 텔레포트 없음.
// ⚠ 직함·팔로우 버튼을 opacity 로 부드럽게 띄우면 poster 에 "반투명 유령" 이 찍힌다 (실측 2026-09-12).
// 그래서 (1) 펼치기는 ease-in — 다 펼쳐지는 마지막 한 프레임이 가장 크게 변하고,
// (2) 직함·버튼은 바로 그 프레임에 steps(1, end) 로 컷 등장하며,
// (3) 되돌아오기는 linear 로 여러 프레임에 고르게 나눠 그 프레임을 넘지 않게 한다.
// → 최대 변화 프레임 = "완전히 펼쳐진 타일 + 또렷한 직함·버튼" 이 poster 가 된다. ──
.tg.is-demo .tg__tile {
animation-name: tg-grow;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.tg.is-demo .tg__role,
.tg.is-demo .tg__follow {
animation-name: tg-detail;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tg.is-demo .tg__tile:nth-child(2),
.tg.is-demo .tg__tile:nth-child(2) .tg__role,
.tg.is-demo .tg__tile:nth-child(2) .tg__follow { animation-delay: 583ms; }
.tg.is-demo .tg__tile:nth-child(3),
.tg.is-demo .tg__tile:nth-child(3) .tg__role,
.tg.is-demo .tg__tile:nth-child(3) .tg__follow { animation-delay: 1166ms; }
@keyframes tg-grow {
0% { flex-grow: 1; animation-timing-function: cubic-bezier(.55, 0, .85, .25); }
12.2% { flex-grow: 2.6; }
16.4% { flex-grow: 2.6; }
28.9% { flex-grow: 1; }
100% { flex-grow: 1; }
}
@keyframes tg-detail {
0% { opacity: 0; transform: translateY(8px); }
12.2% { opacity: 1; transform: translateY(0); }
20% { opacity: 0; transform: translateY(8px); }
100% { opacity: 0; transform: translateY(8px); }
}
@media (max-width: 340px) {
.tg { width: 272px; }
.tg__head { gap: $sp-1; margin-bottom: $sp-2; font-size: 12px; line-height: 15px; }
.tg__row { gap: $sp-1; }
.tg__tile { height: 104px; }
.tg__main { gap: 0; padding: $sp-2 $sp-1 $sp-8; }
.tg__avatar { width: 28px; height: 28px; font-size: 11px; line-height: 14px; }
.tg__name { font-size: 12px; line-height: 15px; }
.tg__role { font-size: 10px; line-height: 12px; }
.tg__follow { left: $sp-1; right: $sp-1; bottom: $sp-2; height: 28px; font-size: 11px; line-height: 13px; }
.tg__follow svg { width: 12px; height: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.tg.is-demo .tg__tile,
.tg.is-demo .tg__role,
.tg.is-demo .tg__follow { animation: none !important; }
.tg__tile, .tg__role, .tg__follow { transition: 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);
}
.tg {
width: 320px;
}
.tg__head {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 12px;
font-weight: 700;
font-size: 13px;
line-height: 18px;
color: #fff7e6;
}
.tg__head svg {
flex: none;
color: rgba(255, 255, 255, 0.55);
}
.tg__row {
display: flex;
gap: 8px;
}
.tg__tile {
position: relative;
flex: 1 1 0;
min-width: 0;
height: 136px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.07);
overflow: hidden;
flex-grow: 1;
transition: flex-grow 300ms cubic-bezier(0.2, 0.8, 0.2, 1), background 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tg__tile.is-open {
flex-grow: 2.6;
background: rgba(255, 255, 255, 0.15);
}
.tg__main {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px 8px 40px;
border: 0;
background: transparent;
color: #fff7e6;
text-align: center;
cursor: pointer;
touch-action: manipulation;
}
.tg__main:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
border-radius: 16px;
}
.tg__avatar {
display: flex;
align-items: center;
justify-content: center;
flex: none;
width: 36px;
height: 36px;
border-radius: 50%;
color: #17141a;
font-weight: 800;
font-size: 13px;
line-height: 16px;
}
.tg__avatar--a {
background: linear-gradient(135deg, #2f6df6, #b9a0ff);
}
.tg__avatar--b {
background: linear-gradient(135deg, #ff9ebb, #b9a0ff);
}
.tg__avatar--c {
background: linear-gradient(135deg, #4cd4a6, #6ec8ff);
}
.tg__name {
max-width: 100%;
font-weight: 800;
font-size: 13px;
line-height: 17px;
color: #fff7e6;
white-space: nowrap;
}
.tg__role {
max-width: 100%;
font-weight: 600;
font-size: 11px;
line-height: 14px;
color: rgba(255, 255, 255, 0.62);
white-space: nowrap;
opacity: 0;
transform: translateY(8px);
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tg__tile.is-open .tg__role {
opacity: 1;
transform: translateY(0);
}
.tg__follow {
position: absolute;
left: 8px;
right: 8px;
bottom: 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 28px;
border: 0;
border-radius: 12px;
background: #2f6df6;
color: #fff;
font-weight: 700;
font-size: 12px;
line-height: 14px;
white-space: nowrap;
cursor: pointer;
touch-action: manipulation;
opacity: 0;
transform: translateY(8px);
pointer-events: none;
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), background 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tg__tile.is-open .tg__follow {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.tg__follow:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.tg__follow.is-on {
background: rgba(255, 255, 255, 0.18);
color: #fff7e6;
}
.tg__f-off,
.tg__f-on {
display: inline-flex;
align-items: center;
gap: 4px;
}
.tg__f-on {
display: none;
}
.tg__follow.is-on .tg__f-off {
display: none;
}
.tg__follow.is-on .tg__f-on {
display: inline-flex;
}
.tg.is-demo .tg__tile {
animation-name: tg-grow;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.tg.is-demo .tg__role,
.tg.is-demo .tg__follow {
animation-name: tg-detail;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tg.is-demo .tg__tile:nth-child(2),
.tg.is-demo .tg__tile:nth-child(2) .tg__role,
.tg.is-demo .tg__tile:nth-child(2) .tg__follow {
animation-delay: 583ms;
}
.tg.is-demo .tg__tile:nth-child(3),
.tg.is-demo .tg__tile:nth-child(3) .tg__role,
.tg.is-demo .tg__tile:nth-child(3) .tg__follow {
animation-delay: 1166ms;
}
@keyframes tg-grow {
0% {
flex-grow: 1;
animation-timing-function: cubic-bezier(0.55, 0, 0.85, 0.25);
}
12.2% {
flex-grow: 2.6;
}
16.4% {
flex-grow: 2.6;
}
28.9% {
flex-grow: 1;
}
100% {
flex-grow: 1;
}
}
@keyframes tg-detail {
0% {
opacity: 0;
transform: translateY(8px);
}
12.2% {
opacity: 1;
transform: translateY(0);
}
20% {
opacity: 0;
transform: translateY(8px);
}
100% {
opacity: 0;
transform: translateY(8px);
}
}
@media (max-width: 340px) {
.tg {
width: 272px;
}
.tg__head {
gap: 4px;
margin-bottom: 8px;
font-size: 12px;
line-height: 15px;
}
.tg__row {
gap: 4px;
}
.tg__tile {
height: 104px;
}
.tg__main {
gap: 0;
padding: 8px 4px 32px;
}
.tg__avatar {
width: 28px;
height: 28px;
font-size: 11px;
line-height: 14px;
}
.tg__name {
font-size: 12px;
line-height: 15px;
}
.tg__role {
font-size: 10px;
line-height: 12px;
}
.tg__follow {
left: 4px;
right: 4px;
bottom: 8px;
height: 28px;
font-size: 11px;
line-height: 13px;
}
.tg__follow svg {
width: 12px;
height: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.tg.is-demo .tg__tile,
.tg.is-demo .tg__role,
.tg.is-demo .tg__follow {
animation: none !important;
}
.tg__tile, .tg__role, .tg__follow {
transition: none;
}
}// 실제 동작: 호버·클릭·화살표로 타일 하나가 넓어지고(flex-grow) 그 안의 직함·팔로우가 올라온다.
var root = document.querySelector('.tg');
var tiles = [].slice.call(document.querySelectorAll('.tg__tile'));
var mains = tiles.map(function (t) { return t.querySelector('.tg__main'); });
var at = 0;
function open(i) {
at = Math.max(0, Math.min(i, tiles.length - 1));
tiles.forEach(function (t, k) {
t.classList.toggle('is-open', k === at);
mains[k].setAttribute('aria-expanded', String(k === at));
});
}
tiles.forEach(function (tile, i) {
tile.addEventListener('pointerenter', function () { root.classList.remove('is-demo'); open(i); });
mains[i].addEventListener('click', function () { root.classList.remove('is-demo'); open(i); });
mains[i].addEventListener('keydown', function (e) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault();
root.classList.remove('is-demo');
var next = (i + (e.key === 'ArrowRight' ? 1 : tiles.length - 1)) % tiles.length;
open(next);
mains[next].focus({ preventScroll: true });
});
tile.querySelector('.tg__follow').addEventListener('click', function () {
root.classList.remove('is-demo');
var b = tile.querySelector('.tg__follow');
var on = b.getAttribute('aria-pressed') !== 'true';
b.setAttribute('aria-pressed', String(on));
b.classList.toggle('is-on', on);
});
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TeamGridCard.module.scss";
export type TeamGridCardProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault(): void };
// useRef 는 항상 `T | null` 을 돌려주므로 목록 보관용 상자는 널이 아닌 타입으로 좁혀 쓴다
type Box<T> = { current: T };
type Member = { initials: string; name: string; role: string; tone: string };
const MEMBERS: Member[] = [
{ initials: "JH", name: "강주하", role: "팀 리드", tone: "a" },
{ initials: "ES", name: "노은설", role: "프로덕트 디자이너", tone: "b" },
{ initials: "GO", name: "임가온", role: "프론트엔드 개발자", tone: "c" },
];
const IconUsers = () => (
<svg viewBox="0 0 256 256" width="15" height="15" aria-hidden="true"><g fill="currentColor"><path d="M168 144a40 40 0 1 1-40-40a40 40 0 0 1 40 40M64 56a32 32 0 1 0 32 32a32 32 0 0 0-32-32m128 0a32 32 0 1 0 32 32a32 32 0 0 0-32-32" opacity=".2"/><path d="M244.8 150.4a8 8 0 0 1-11.2-1.6A51.6 51.6 0 0 0 192 128a8 8 0 0 1 0-16a24 24 0 1 0-23.24-30a8 8 0 1 1-15.5-4A40 40 0 1 1 219 117.51a67.94 67.94 0 0 1 27.43 21.68a8 8 0 0 1-1.63 11.21M190.92 212a8 8 0 1 1-13.85 8a57 57 0 0 0-98.15 0a8 8 0 1 1-13.84-8a72.06 72.06 0 0 1 33.74-29.92a48 48 0 1 1 58.36 0A72.06 72.06 0 0 1 190.92 212M128 176a32 32 0 1 0-32-32a32 32 0 0 0 32 32m-56-56a8 8 0 0 0-8-8a24 24 0 1 1 23.24-30a8 8 0 1 0 15.5-4A40 40 0 1 0 37 117.51a67.94 67.94 0 0 0-27.4 21.68a8 8 0 1 0 12.8 9.61A51.6 51.6 0 0 1 64 128a8 8 0 0 0 8-8"/></g></svg>
);
const IconUserPlus = () => (
<svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg>
);
const IconCheck = () => (
<svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
);
export default function TeamGridCard({
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: TeamGridCardProps) {
const [at, setAt] = useState(0);
const [followed, setFollowed] = useState<boolean[]>([false, false, false]);
const mainsRef = useRef<(HTMLButtonElement | null)[]>([null, null, null]) as Box<(HTMLButtonElement | null)[]>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function onKey(e: KeyEvt, i: number): void {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
const next = (i + (e.key === "ArrowRight" ? 1 : MEMBERS.length - 1)) % MEMBERS.length;
setAt(next);
const el = mainsRef.current[next];
if (el) el.focus({ preventScroll: true });
}
function toggleFollow(i: number): void {
setFollowed(followed.map((v: boolean, k: number): boolean => (k === i ? !v : v)));
}
return (
<div className={styles.tg} style={vars}>
<p className={styles.head}><IconUsers />팀 멤버</p>
<div className={styles.row}>
{MEMBERS.map((m: Member, i: number) => {
const open = i === at;
const tone = m.tone === "a" ? styles.toneA : m.tone === "b" ? styles.toneB : styles.toneC;
return (
<div
key={m.initials}
className={open ? `${styles.tile} ${styles.isOpen}` : styles.tile}
onPointerEnter={(): void => setAt(i)}
>
<button
type="button"
ref={(el: HTMLButtonElement | null): void => { mainsRef.current[i] = el; }}
className={styles.main}
aria-expanded={open}
onClick={(): void => setAt(i)}
onKeyDown={(e: KeyEvt): void => onKey(e, i)}
>
<span className={`${styles.avatar} ${tone}`} aria-hidden="true">{m.initials}</span>
<span className={styles.name}>{m.name}</span>
<span className={styles.role}>{m.role}</span>
</button>
<button
type="button"
className={followed[i] ? `${styles.follow} ${styles.isOn}` : styles.follow}
aria-pressed={followed[i]}
tabIndex={open ? undefined : -1}
onClick={(): void => toggleFollow(i)}
>
<span className={styles.fOff}><IconUserPlus />팔로우</span>
<span className={styles.fOn}><IconCheck />팔로잉</span>
</button>
</div>
);
})}
</div>
</div>
);
}09 Team grid expand — Measured area 22.797% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · 9 Profile Card Design Styles, Copy-Paste Ready
The post this demo lives in
9 Profile Card Design Styles, Copy-Paste ReadyProfile card design is the job of deciding what material a fixed block of content wears: avatar, name, role, stats, foll See all demos in this post
If you like this effect - similar demos
Landing sections04 Profile detail — a card that measures bio height to decide whether read-more even appears9 Team Page Design Patterns (Copy-Paste)
Landing sections04 Team members grid9 Landing Page Sections That Build Trust
Landing sections02 Feature card grid — cards revealing in order from a visible-ratio valueHero Section Design Examples: 9 Patterns