Team Grid
A four-state team section — equal-height member cards with initials-fallback avatars, optional bios and real social links, groupable by department.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/team-grid.jsonPrompt
Build a React + TypeScript + Tailwind "TeamGrid" block (lucide-react glyphs)
with zod.
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; heading?; subheading?;
items: { id, name, role, bio?, avatarUrl?, initials?, department?,
socials?: { channel, label, href }[] }[] }.
- `channel` is a neutral enum (chat | code | email | photo | post | website |
work), not a brand name: the component picks the glyph, the platform name
lives in `label` and becomes part of the link's accessible name.
- `href` is refined at parse time to reject dead anchors ("#" and friends), so
a decorative fake link cannot enter the data in the first place.
- Component props = z.infer of the schema plus grouped?: boolean,
onRetry?: () => void, className.
Behavior
- Four first-class branches: loading (six skeleton cards mirroring the real
card anatomy, plus one sr-only role=status line), empty, error (message and
a "Try again" button only when onRetry exists), ready.
- The avatar degrades, never breaks: no avatarUrl → initials; onError →
initials; and because a cached image on a prerendered page can finish before
hydration attaches onError, a ref callback re-probes
`complete && naturalWidth === 0`. The avatar is keyed by its URL so swapping
data drops the stale failure flag instead of resetting state in an effect.
- Initials are derived: first letters of the first two whitespace-separated
words; a single word containing no ASCII letter (CJK names) yields its first
two characters, so 李雷 shows 李雷 rather than 李. `items.initials` overrides
the heuristic for honorifics, mononyms and stage names.
- grouped=true buckets members by `department` in first-appearance order —
never alphabetised behind the data's back. Members without a department form
one bucket with no heading. Each bucket's <ul> is aria-labelledby its
heading, and that heading renders as h3 under a section heading, h2 without
one, so heading levels never skip.
- No member cap and no fixed-height clipping: every entry in the array renders,
and a long bio makes its row taller instead of being cut off.
- Social links are real <a href> straight from the contract, with
aria-label "<label>, <name>" so a screen reader hears whose profile it is.
http(s) destinations open in a new tab with rel=noreferrer; mailto: and
in-app routes stay in the current tab.
Rendering & styling
- Semantic tokens only: bg-card cards, bg-muted avatar well and skeletons,
text-muted-foreground for role/bio/glyphs, border plus
hover:border-primary/40 as the only accent.
- The grid is container-driven rather than breakpoint-driven:
grid-cols-[repeat(auto-fill,minmax(min(13rem,100%),1fr))]. auto-fill (not
auto-fit) keeps a single remaining member from stretching across a full row.
- Cards are grid items with h-full flex-col, so every card in a row shares one
height regardless of name/role/bio length, and the social row uses mt-auto to
sit on a shared baseline.
- Names and roles use break-words — never break-all, which collapses a column's
min-content to a single character — and the text column is min-w-0 so one
unbroken 35-character surname cannot push the card past its track.
- Skeleton pulses and hover transitions stop under prefers-reduced-motion;
focus-visible:ring-2 ring-ring on every link and button.
- The avatar image is decorative (alt="") and the initials span is aria-hidden:
the name is on the next line, reading it twice is noise.
Customization levers
- Card orientation: drop items-center/text-center for a left-aligned card —
nothing else in the layout depends on the centering.
- Density: 13rem is the column floor; raise it for wide portrait cards, lower
it for a compact directory. Avatar size-16 and the grid gap tune separately.
- Channel glyphs: CHANNEL_ICONS is a flat map — swap it wholesale for real
brand marks (simple-icons) without touching link markup or accessibility.
- Optional sub-blocks: bio, socials, heading/subheading and grouping are each
independently optional; delete a branch and the card converges on its own.
- Grouping key: `department` is just a string — repoint the bucket key at
location, guild or squad by changing the single item.department read.
- Promote names to headings (h3/h4) when the page needs screen-reader jump
targets; they are plain text here so a 40-person section does not flood the
heading outline.Concepts
- Initials fallback chain — a missing URL, a failed request and an image that already failed before hydration all land on the same branch, so a card can show a letter but never a broken-image icon.
- Equal-height cards — cards are grid items stretched by the row, and the social row is pushed down with
mt-auto; a two-character name and a three-line job title end up on the same baseline instead of one card standing taller. - Container-driven columns —
auto-fillwith amin(13rem, 100%)floor lets the same block reflow inside a sidebar, a docs column or a full-bleed page without viewport breakpoints;auto-fitwould inflate a lone card to full width. - First-appearance bucketing — grouping preserves the order departments appear in the data, so editors control the reading order; members with no department collect into a single unlabelled bucket — held in the same first-appearance order — instead of inventing a category name for them.
- Named links, not empty ones — each icon link carries
"<platform>, <person>"as its accessible name; icon-only links without one are read as a row of anonymous "link" announcements. - Contract-driven four states — team pages fetch from a CMS that can be slow, empty or down, so
statusis a rendering branch rather than an afterthought spinner.
Site Footer
A contract-driven site footer with a brand block, named social links, mobile-collapsible link columns and a legal bottom bar.
Contact Section
A contact block whose subject is the submit state machine — sending, confirmed, or failed with every character preserved — beside contact rows that derive their own mailto/tel/map links.