Team Grid
Introduce the people behind the work. Portrait cards with useful roles, short introductions, and a full profile a visitor can open.
EXAMPLE STUDIO TEAM
A few good people.
Mira Patel
Strategy & directionMira helps teams find the clearest version of their story. She brings audience research, thoughtful questions, and a practical plan to every new project. Before a design takes shape, she works with clients to understand the people it needs to serve. Her work connects a brand’s ambition to the decisions a visitor makes on a real website.
Daniel Park
Product & designDaniel turns complex ideas into calm, useful experiences. His work moves between visual systems, product details, and the everyday paths people take through a website. He cares about clear hierarchy, strong typography, and giving the content room to do its job. He works closely with engineers so that the finished product feels as considered as the first design.
Sofia Reed
Creative engineeringSofia brings ideas to life through accessible, resilient interfaces. She builds the details that make a site feel responsive, from image galleries to well-labeled forms. Her approach starts with a useful foundation, then adds motion where it helps someone understand a change. She makes sure the experience works just as well with a keyboard, on a phone, and at a slower connection.
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import './team-grid.css';
export interface TeamGridItem {
id: string;
name: string;
role: string;
bio: string;
portrait: ReactNode;
links?: { label: string; href: string }[];
}
export interface TeamGridProps {
items: TeamGridItem[];
title?: string;
className?: string;
}
/** Portrait cards with a full, keyboard-accessible profile for each person. */
export function TeamGrid({ items, title = 'Good people. Different perspectives.', className = '' }: TeamGridProps) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const selected = items.find(item => item.id === selectedId);
const isOpen = Boolean(selected);
const viewer = useRef<HTMLDialogElement>(null);
const opener = useRef<HTMLButtonElement | null>(null);
const instance = useId();
const titleId = `ac-team-title-${instance}`;
const profileId = `ac-team-profile-${instance}`;
const profileTitleId = `${profileId}-title`;
useEffect(() => {
if (!isOpen) return;
const modal = viewer.current;
if (!modal) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
if (!modal.open) modal.showModal();
return () => {
if (modal.open) modal.close();
document.body.style.overflow = previousOverflow;
opener.current?.focus({ preventScroll: true });
};
}, [isOpen]);
return (
<section className={`ac-team-grid ${className}`.trim()} aria-labelledby={title ? titleId : undefined} aria-label={title ? undefined : 'Our team'}>
{title && <header className="ac-team-grid-heading"><span>THE PEOPLE BEHIND THE WORK</span><h2 id={titleId}>{title}</h2></header>}
{items.length ? <div className="ac-team-grid-cards">{items.map((person, index) => <article className="ac-team-grid-card" key={person.id}>
<div className="ac-team-grid-portrait" aria-hidden="true" ref={element => { if (element) element.inert = true; }}>{person.portrait}<span className="ac-team-grid-number">{String(index + 1).padStart(2, '0')}</span></div>
<div className="ac-team-grid-person"><h3>{person.name}</h3><span>{person.role}</span></div>
<p className="ac-team-grid-preview">{person.bio}</p>
<button type="button" className="ac-team-grid-meet" aria-haspopup="dialog" aria-controls={profileId} aria-expanded={selectedId === person.id && isOpen} onClick={event => { opener.current = event.currentTarget; setSelectedId(person.id); }}>Meet {person.name}<span aria-hidden="true">↗</span></button>
</article>)}</div> : <p className="ac-team-grid-empty">Team profiles will appear here.</p>}
<dialog ref={viewer} id={profileId} className="ac-team-grid-dialog" aria-labelledby={profileTitleId} onCancel={event => { event.preventDefault(); setSelectedId(null); }} onClose={() => { if (!viewer.current?.open) setSelectedId(null); }}>
<header><span>MEET THE TEAM</span><button className="ac-team-grid-close" type="button" onClick={() => setSelectedId(null)}>Close <span aria-hidden="true">×</span></button></header>
{selected && <div className="ac-team-grid-profile"><div className="ac-team-grid-profile-portrait" aria-hidden="true" ref={element => { if (element) element.inert = true; }}>{selected.portrait}</div><div className="ac-team-grid-profile-copy"><span className="ac-team-grid-profile-role">{selected.role}</span><h2 id={profileTitleId}>{selected.name}</h2><p>{selected.bio}</p>{Boolean(selected.links?.length) && <nav aria-label={`${selected.name} links`} className="ac-team-grid-links">{selected.links?.map((link, index) => <a key={`${link.href}-${index}`} href={link.href}>{link.label}<span aria-hidden="true">↗</span></a>)}</nav>}</div></div>}
</dialog>
</section>
);
}
Installation
Run this in your shadcn-configured React project. The component and stylesheet are added together.
npx shadcn@latest add http://localhost:5173/r/team-grid.jsonThis command uses the current site address. For localhost, keep this preview running on the same computer. Files follow your configured components alias.
Usage
Import the component and make the details your own.
import { TeamGrid } from '@/components/anticraft/TeamGrid';
export function Example() {
return (
<TeamGrid
title="Good people. Different perspectives."
items={[
{
id: 'mira', name: 'Mira Patel', role: 'Strategy & direction',
bio: 'Mira helps teams find the clearest version of their story. She brings audience research, thoughtful questions, and a practical plan to every new project.',
portrait: <img src="/images/mira.jpg" alt="" />,
links: [{ label: 'Read Mira’s journal', href: '/journal/mira' }],
},
{
id: 'daniel', name: 'Daniel Park', role: 'Product & design',
bio: 'Daniel turns complex ideas into calm, useful experiences. His work moves between visual systems, product details, and the everyday paths people take through a website.',
portrait: <img src="/images/daniel.jpg" alt="" />,
},
{
id: 'sofia', name: 'Sofia Reed', role: 'Creative engineering',
bio: 'Sofia brings ideas to life through accessible, resilient interfaces. She builds the details that make a site feel responsive and works closely with design from the first sketch.',
portrait: <img src="/images/sofia.jpg" alt="" />,
},
]}
/>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | TeamGridItem[] | Required | People with a unique id, name, role, full bio string, and decorative ReactNode portrait. Optional links are ordinary labeled destinations in the full profile. An empty list displays a quiet placeholder. |
items[].portrait | ReactNode | Required | A decorative portrait or illustration, rendered inert and hidden from assistive technology because the adjacent heading identifies the person. Keep links and controls out of portraits. Supply your own image files for the example paths. |
items[].bio | string | Required | The complete introduction. Cards show a preview of up to three lines; the profile shows the whole text and preserves paragraph breaks. |
items[].links | { label: string; href: string }[] | undefined | Optional ordinary profile links with descriptive visible labels. Links appear outside the decorative portrait. Replace example paths with real destinations in your site; omit links when no destination exists. |
title | string | 'Good people. Different perspectives.' | Visible heading and accessible section name. An empty string hides the heading while retaining an Our team accessible label. |
className | string | '' | Additional classes. Customize --ac-team-surface, --ac-team-ink, --ac-team-muted, --ac-team-line, and --ac-team-accent on this element. |
Motion & accessibility
Every card keeps its name, role, introduction, and Meet button visible on pointer and touch screens. A gentle portrait scale on hover is decorative and removed for reduced motion. Meet opens a native modal with the complete bio and optional profile links. Normal Tab navigation stays inside the open modal; Escape and the visible Close button dismiss it and return focus to the opener. Background scrolling is restored on close or unmount. Removing an open person from items closes their profile safely. The grid responds to its own container, moving from three to two to one column; the compact live demo uses a horizontal rail to keep the full controls visible. All example people and biographies are fictional.
Use semantic content and meaningful labels in your composition. Check contrast and keyboard behavior with your own styles and content.