09팀 카드 그리드 확장
나란히 선 팀원 셋 중 하나가 넓어지면서 직함과 팔로우 버튼이 아래에서 올라오고, 나머지 둘은 같은 시간 동안 좁아져 전체 폭이 그대로 유지됩니다.
쓰임회사 소개 페이지의 팀 섹션처럼 한 줄에 여러 사람을 두고 하나씩 자세히 보여줄 때
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>
팀 멤버
</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">강주하</span>
<span class="tg__role">팀 리드</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>팔로우</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>팔로잉</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">노은설</span>
<span class="tg__role">프로덕트 디자이너</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>팔로우</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>팔로잉</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">임가온</span>
<span class="tg__role">프론트엔드 개발자</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>팔로우</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>팔로잉</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 팀 카드 그리드 확장 — 측정 누적면적 22.797% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 프로필 카드 디자인 9종 — 복붙하면 되는 스타일 모음
이 데모가 속한 글
프로필 카드 디자인 9종 — 복붙하면 되는 스타일 모음프로필 카드 디자인은 아바타·이름·직함·소셜 스탯·팔로우 버튼이라는 한 덩어리를 어떤 재질로 보여줄지 정하는 일입니다. 아홉 개는 그 안쪽 구조가 전부 같고, 바깥 재질과 움직임만 다릅니다. 이 글의 데모 전부 보기


