Blocks

Team Grid

A four-state team section — equal-height member cards with initials-fallback avatars, optional bios and real social links, groupable by department.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  AtSign,
  Briefcase,
  Camera,
  GitBranch,
  Globe,
  type LucideIcon,
  Mail,
  MessageCircle,
} from "lucide-react"
import { cn } from "@/lib/utils"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/team-grid.json

Prompt

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 columnsauto-fill with a min(13rem, 100%) floor lets the same block reflow inside a sidebar, a docs column or a full-bleed page without viewport breakpoints; auto-fit would 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 status is a rendering branch rather than an afterthought spinner.

On This Page