Design team
Posters and print
Design team
Posters and print
import { useState } from 'react';
const PEOPLE = [
{ i: 'MH', n: 'Mira Holt' },
{ i: 'DK', n: 'Dana Kole' },
{ i: 'AR', n: 'Ana Ruiz' },
{ i: 'JB', n: 'Jun Bae' },
{ i: 'TS', n: 'Tom Sato' },
{ i: 'LP', n: 'Lea Park' },
{ i: 'OM', n: 'Omar Malik' },
];
const A = 30;
const SHOWN = 4;
const css = `
.card-team { box-sizing: border-box; width: 240px; padding: 14px; border-radius: 16px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.card-team__title { margin: 0; font-size: 13px; font-weight: 600; color: light-dark(#373a40, #f1f2f3); }
.card-team__sub { margin: 2px 0 14px; font-size: 11px; color: light-dark(#5f626a, #a7abb2); }
.card-team__group { position: relative; display: block; width: 100%; height: ${A}px; padding: 0; border: 0; background: none; cursor: pointer; }
.card-team__group:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 4px; border-radius: ${A / 2}px; }
.card-team__face {
position: absolute;
left: 0;
top: 0;
display: grid;
place-items: center;
box-sizing: border-box;
width: ${A}px;
height: ${A}px;
border: 2px solid light-dark(#f3f4f7, #1a1c1f);
border-radius: 50%;
font-size: 10px;
font-weight: 700;
transition: transform 0.45s cubic-bezier(0.3, 1.4, 0.5, 1), opacity 0.25s;
}
.card-team__face:nth-child(4n + 1) { background: light-dark(#50535b, #c6c8cd); color: light-dark(#fbfcfd, #121417); }
.card-team__face:nth-child(4n + 2) { background: light-dark(#cbced3, #393c41); color: light-dark(#373a40, #f1f2f3); }
.card-team__face:nth-child(4n + 3) { background: light-dark(#5f626a, #a7abb2); color: light-dark(#fbfcfd, #121417); }
.card-team__face:nth-child(4n + 4) { background: light-dark(#dbdde1, #2e3035); color: light-dark(#373a40, #f1f2f3); }
.card-team__more { background: light-dark(#ebedf0, #232529) !important; color: light-dark(#42454c, #e5e6e9) !important; }
.card-team__face--hot { z-index: 2; }
.card-team__caption { height: 16px; margin: 10px 0 0; font-size: 11.5px; color: light-dark(#50535b, #c6c8cd); }
@media (prefers-reduced-motion: reduce) {
.card-team__face { transition: opacity 0.2s; }
}
`;
export function TeamCard() {
const [open, setOpen] = useState(false);
const [hot, setHot] = useState<number | null>(null);
// Stacked, four faces overlap with a +3 chip; spread, all seven line up a beat apart and the chip folds away.
const place = (i: number): React.CSSProperties => {
const delay = `${(open ? i : PEOPLE.length - i) * 35}ms`;
if (open) return { transform: `translateX(${i * (A + 2)}px) scale(${hot === i ? 1.12 : 1})`, transitionDelay: hot === null ? delay : '0ms' };
if (i < SHOWN) return { transform: `translateX(${i * 20}px)`, transitionDelay: delay };
return { transform: `translateX(${SHOWN * 20}px) scale(0.4)`, opacity: 0, transitionDelay: delay };
};
const caption = open ? (hot !== null ? PEOPLE[hot].n : `${PEOPLE.length} members`) : `${PEOPLE[0].n.split(' ')[0]}, ${PEOPLE[1].n.split(' ')[0]} and ${PEOPLE.length - 2} others`;
return (
<>
<style>
{css}
</style>
<article className="card-team" onPointerLeave={() => setOpen(false)}>
<p className="card-team__title">Design team</p>
<p className="card-team__sub">Posters and print</p>
<button
type="button"
className="card-team__group"
aria-expanded={open}
aria-label={`Team: ${PEOPLE.map((p) => p.n).join(', ')}`}
onPointerEnter={(e) => e.pointerType === 'mouse' && setOpen(true)}
onClick={() => setOpen((o) => !o)}
>
{PEOPLE.map((p, i) => (
<span
key={p.i}
className={`card-team__face${hot === i ? ' card-team__face--hot' : ''}`}
style={place(i)}
onPointerEnter={() => open && setHot(i)}
onPointerLeave={() => setHot(null)}
aria-hidden="true"
>
{p.i}
</span>
))}
<span className="card-team__face card-team__more" style={{ transform: `translateX(${SHOWN * 20}px) scale(${open ? 0.4 : 1})`, opacity: open ? 0 : 1 }} aria-hidden="true">
+{PEOPLE.length - SHOWN}
</span>
</button>
<p className="card-team__caption" aria-hidden="true">
{caption}
</p>
</article>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardTeam(el) on the mounted element instead.
<article class="card-team">
<p class="card-team__title">Design team</p>
<p class="card-team__sub">Posters and print</p>
<button type="button" class="card-team__group" aria-expanded="false" aria-label="Team: Mira Holt, Dana Kole, Ana Ruiz, Jun Bae, Tom Sato, Lea Park, Omar Malik">
<span class="card-team__face" style="transform:translateX(0px);transition-delay:245ms" aria-hidden="true">MH</span>
<span class="card-team__face" style="transform:translateX(20px);transition-delay:210ms" aria-hidden="true">DK</span>
<span class="card-team__face" style="transform:translateX(40px);transition-delay:175ms" aria-hidden="true">AR</span>
<span class="card-team__face" style="transform:translateX(60px);transition-delay:140ms" aria-hidden="true">JB</span>
<span class="card-team__face" style="transform:translateX(80px) scale(0.4);opacity:0;transition-delay:105ms" aria-hidden="true">TS</span>
<span class="card-team__face" style="transform:translateX(80px) scale(0.4);opacity:0;transition-delay:70ms" aria-hidden="true">LP</span>
<span class="card-team__face" style="transform:translateX(80px) scale(0.4);opacity:0;transition-delay:35ms" aria-hidden="true">OM</span>
<span class="card-team__face card-team__more" style="transform:translateX(80px) scale(1);opacity:1" aria-hidden="true">+3</span>
</button>
<p class="card-team__caption" aria-hidden="true">Mira, Dana and 5 others</p>
</article>.card-team { box-sizing: border-box; width: 240px; padding: 14px; border-radius: 16px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.card-team__title { margin: 0; font-size: 13px; font-weight: 600; color: light-dark(#373a40, #f1f2f3); }
.card-team__sub { margin: 2px 0 14px; font-size: 11px; color: light-dark(#5f626a, #a7abb2); }
.card-team__group { position: relative; display: block; width: 100%; height: 30px; padding: 0; border: 0; background: none; cursor: pointer; }
.card-team__group:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 4px; border-radius: 15px; }
.card-team__face {
position: absolute;
left: 0;
top: 0;
display: grid;
place-items: center;
box-sizing: border-box;
width: 30px;
height: 30px;
border: 2px solid light-dark(#f3f4f7, #1a1c1f);
border-radius: 50%;
font-size: 10px;
font-weight: 700;
transition: transform 0.45s cubic-bezier(0.3, 1.4, 0.5, 1), opacity 0.25s;
}
.card-team__face:nth-child(4n + 1) { background: light-dark(#50535b, #c6c8cd); color: light-dark(#fbfcfd, #121417); }
.card-team__face:nth-child(4n + 2) { background: light-dark(#cbced3, #393c41); color: light-dark(#373a40, #f1f2f3); }
.card-team__face:nth-child(4n + 3) { background: light-dark(#5f626a, #a7abb2); color: light-dark(#fbfcfd, #121417); }
.card-team__face:nth-child(4n + 4) { background: light-dark(#dbdde1, #2e3035); color: light-dark(#373a40, #f1f2f3); }
.card-team__more { background: light-dark(#ebedf0, #232529) !important; color: light-dark(#42454c, #e5e6e9) !important; }
.card-team__face--hot { z-index: 2; }
.card-team__caption { height: 16px; margin: 10px 0 0; font-size: 11.5px; color: light-dark(#50535b, #c6c8cd); }
@media (prefers-reduced-motion: reduce) {
.card-team__face { transition: opacity 0.2s; }
}function initCardTeam(root) {
var A = 30;
var SHOWN = 4;
var group = root.querySelector('.card-team__group');
var faces = root.querySelectorAll('.card-team__face:not(.card-team__more)');
var more = root.querySelector('.card-team__more');
var caption = root.querySelector('.card-team__caption');
var names = group.getAttribute('aria-label').replace('Team: ', '').split(', ');
var open = false;
var hot = null;
// Stacked, four faces overlap with a +3 chip; spread, all seven line up a beat apart and the chip folds away.
function render() {
group.setAttribute('aria-expanded', String(open));
faces.forEach(function (face, i) {
var s = face.style;
var delay = (open ? i : faces.length - i) * 35 + 'ms';
face.classList.toggle('card-team__face--hot', hot === i);
if (open) {
s.transform = 'translateX(' + i * (A + 2) + 'px) scale(' + (hot === i ? 1.12 : 1) + ')';
s.opacity = '';
s.transitionDelay = hot === null ? delay : '0ms';
} else if (i < SHOWN) {
s.transform = 'translateX(' + i * 20 + 'px)';
s.opacity = '';
s.transitionDelay = delay;
} else {
s.transform = 'translateX(' + SHOWN * 20 + 'px) scale(0.4)';
s.opacity = 0;
s.transitionDelay = delay;
}
});
more.style.transform = 'translateX(' + SHOWN * 20 + 'px) scale(' + (open ? 0.4 : 1) + ')';
more.style.opacity = open ? 0 : 1;
caption.textContent = open
? hot !== null
? names[hot]
: names.length + ' members'
: names[0].split(' ')[0] + ', ' + names[1].split(' ')[0] + ' and ' + (names.length - 2) + ' others';
}
function onEnter(e) {
if (e.pointerType !== 'mouse' || open) return;
open = true;
render();
}
function onClick() {
open = !open;
render();
}
function onLeave() {
if (!open) return;
open = false;
render();
}
function onFace(e) {
if (!open) return;
hot = Array.prototype.indexOf.call(faces, e.currentTarget);
render();
}
function onFaceOut() {
hot = null;
render();
}
group.addEventListener('pointerenter', onEnter);
group.addEventListener('click', onClick);
root.addEventListener('pointerleave', onLeave);
faces.forEach(function (face) {
face.addEventListener('pointerenter', onFace);
face.addEventListener('pointerleave', onFaceOut);
});
return function () {
group.removeEventListener('pointerenter', onEnter);
group.removeEventListener('click', onClick);
root.removeEventListener('pointerleave', onLeave);
faces.forEach(function (face) {
face.removeEventListener('pointerenter', onFace);
face.removeEventListener('pointerleave', onFaceOut);
});
};
}
document.querySelectorAll('.card-team').forEach(initCardTeam);Goal: Create a "Team" card.
Visual behavior: A team card with overlapping avatars: hover the group and they spread apart one after another, the +3 chip folds away into the rest of the team, and pointing at a face names it.
Technique:
- Every avatar is absolutely placed and moved by translateX. Stacked, four overlap and the rest wait behind a +3 chip; spread, all seven line up with a gap.
- Per-avatar transition delays run the spread left to right and the gather right to left, on an overshooting curve.
- When spread, the face under the pointer scales up and the caption switches to that person's name.
Accessibility: The group is a button with aria-expanded and an aria-label listing every member, so the whole team is read at once; hovering opens it for mouse users and a click or Enter toggles it. Under prefers-reduced-motion the faces only fade.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Team" card.
Visual behavior: A team card with overlapping avatars: hover the group and they spread apart one after another, the +3 chip folds away into the rest of the team, and pointing at a face names it.
Technique:
- Every avatar is absolutely placed and moved by translateX. Stacked, four overlap and the rest wait behind a +3 chip; spread, all seven line up with a gap.
- Per-avatar transition delays run the spread left to right and the gather right to left, on an overshooting curve.
- When spread, the face under the pointer scales up and the caption switches to that person's name.
Accessibility: The group is a button with aria-expanded and an aria-label listing every member, so the whole team is read at once; hovering opens it for mouse users and a click or Enter toggles it. Under prefers-reduced-motion the faces only fade.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
A team card with overlapping avatars: hover the group and they spread apart one after another, the +3 chip folds away into the rest of the team, and pointing at a face names it.
The group is a button with aria-expanded and an aria-label listing every member, so the whole team is read at once; hovering opens it for mouse users and a click or Enter toggles it. Under prefers-reduced-motion the faces only fade.