# Team grid

Team members grid with gradient initials avatars, bio reveal on hover, social links and a hiring card.

- Type: block
- Category: Sections
- Tier: Free
- Dependencies: lucide-react, motion

## Install

```bash
npx shadcn@latest add https://facadeui.com/r/team-grid.json
```

## Usage

```tsx
import { TeamGrid } from "@/components/facade/team-grid";

export default function Page() {
  return <TeamGrid />;
}
```

## Source

```tsx
"use client";

import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { ArrowRight, Mail, Plus, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";

export interface TeamSocials {
  linkedin?: string;
  x?: string;
  instagram?: string;
  email?: string;
}

export interface TeamMember {
  name: string;
  role: string;
  bio: string;
  location?: string;
  socials?: TeamSocials;
  /** Optional photo URL. Falls back to a gradient initials avatar. */
  imageSrc?: string;
}

export interface TeamGridProps {
  eyebrow?: string;
  heading?: string;
  description?: string;
  members?: TeamMember[];
  showHiringCard?: boolean;
  hiringHeading?: string;
  hiringText?: string;
  hiringCtaLabel?: string;
  hiringHref?: string;
  openRoles?: number;
  className?: string;
}

const DEFAULT_MEMBERS: TeamMember[] = [
  {
    name: "Jessica Moreno",
    role: "Founder and Managing Broker",
    location: "Miami, FL",
    bio: "Fifteen years selling waterfront homes in Coconut Grove and Coral Gables. Over $480M in closed sales and still answers her own phone.",
    socials: { linkedin: "#", instagram: "#", email: "jessica@baysiderealty.com" },
  },
  {
    name: "Andre Thompson",
    role: "Senior Listing Agent",
    location: "Coral Gables, FL",
    bio: "Former architect who reads floor plans like novels. Andre leads our luxury listings and staging strategy.",
    socials: { linkedin: "#", x: "#", email: "andre@baysiderealty.com" },
  },
  {
    name: "Mei Chen",
    role: "Buyer Specialist",
    location: "Brickell, FL",
    bio: "Helps first-time buyers and relocating families find the right condo, from pre-approval to the keys. Fluent in Mandarin.",
    socials: { linkedin: "#", instagram: "#" },
  },
  {
    name: "Carlos Ruiz",
    role: "Investment Advisor",
    location: "Wynwood, FL",
    bio: "Runs the numbers on multifamily and short-term rentals. Carlos has helped 60+ clients build Miami rental portfolios.",
    socials: { linkedin: "#", x: "#", email: "carlos@baysiderealty.com" },
  },
  {
    name: "Hannah Brooks",
    role: "Marketing Director",
    location: "Miami Beach, FL",
    bio: "Produces the photography, video and campaigns that sell our listings 19 days faster than the county average.",
    socials: { instagram: "#", linkedin: "#" },
  },
  {
    name: "Samuel Okoye",
    role: "Transaction Coordinator",
    location: "Miami, FL",
    bio: "Keeps every deadline, inspection and closing on track so our clients never have to chase paperwork.",
    socials: { linkedin: "#", email: "samuel@baysiderealty.com" },
  },
  {
    name: "Olivia Park",
    role: "Leasing Agent",
    location: "Edgewater, FL",
    bio: "Matches renters with the right building in under a week on average. Knows every concierge on Biscayne by name.",
    socials: { instagram: "#", email: "olivia@baysiderealty.com" },
  },
];

function initials(name: string) {
  return name
    .split(" ")
    .slice(0, 2)
    .map((p) => p[0]?.toUpperCase() ?? "")
    .join("");
}

function hue(name: string) {
  let h = 11;
  for (let i = 0; i < name.length; i++) h = (h * 29 + name.charCodeAt(i)) % 360;
  return h;
}

function LinkedinIcon() {
  return (
    <svg viewBox="0 0 24 24" className="size-4" fill="currentColor" aria-hidden>
      <path d="M20.45 20.45h-3.56v-5.57c0-1.33-.02-3.04-1.85-3.04-1.85 0-2.14 1.45-2.14 2.94v5.67H9.35V9h3.41v1.56h.05c.48-.9 1.64-1.85 3.37-1.85 3.6 0 4.27 2.37 4.27 5.46v6.28zM5.34 7.43a2.06 2.06 0 1 1 0-4.13 2.06 2.06 0 0 1 0 4.13zM7.12 20.45H3.56V9h3.56v11.45z" />
    </svg>
  );
}
function XIcon() {
  return (
    <svg viewBox="0 0 24 24" className="size-3.5" fill="currentColor" aria-hidden>
      <path d="M18.24 2.25h3.31l-7.23 8.26 8.5 11.24h-6.66l-5.21-6.82-5.97 6.82H1.67l7.73-8.84L1.25 2.25h6.83l4.71 6.23 5.45-6.23zm-1.16 17.52h1.83L7.08 4.13H5.12l11.96 15.64z" />
    </svg>
  );
}
function InstagramIcon() {
  return (
    <svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
      <rect x="3" y="3" width="18" height="18" rx="5" />
      <circle cx="12" cy="12" r="4" />
      <circle cx="17.5" cy="6.5" r="1" fill="currentColor" stroke="none" />
    </svg>
  );
}

function SocialLinks({ member }: { member: TeamMember }) {
  const s = member.socials;
  if (!s) return null;
  const items: { key: string; href: string; label: string; icon: React.ReactNode }[] = [];
  if (s.linkedin) items.push({ key: "li", href: s.linkedin, label: `${member.name} on LinkedIn`, icon: <LinkedinIcon /> });
  if (s.x) items.push({ key: "x", href: s.x, label: `${member.name} on X`, icon: <XIcon /> });
  if (s.instagram) items.push({ key: "ig", href: s.instagram, label: `${member.name} on Instagram`, icon: <InstagramIcon /> });
  if (s.email) items.push({ key: "em", href: `mailto:${s.email}`, label: `Email ${member.name}`, icon: <Mail className="size-4" aria-hidden /> });
  return (
    <div className="flex gap-1.5">
      {items.map((it) => (
        <a
          key={it.key}
          href={it.href}
          aria-label={it.label}
          className="flex size-8 items-center justify-center rounded-full border border-border bg-background text-muted-foreground transition hover:border-foreground/30 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
        >
          {it.icon}
        </a>
      ))}
    </div>
  );
}

function MemberCard({ member, index, reduce }: { member: TeamMember; index: number; reduce: boolean }) {
  const h = hue(member.name);
  const [open, setOpen] = React.useState(false);
  return (
    <motion.li
      initial={reduce ? false : { opacity: 0, y: 20 }}
      whileInView={{ opacity: 1, y: 0 }}
      viewport={{ once: true, margin: "-40px 0px" }}
      transition={{ duration: 0.55, delay: (index % 4) * 0.07, ease: [0.22, 1, 0.36, 1] }}
      className="group relative flex flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-sm transition-shadow hover:shadow-lg"
      onMouseEnter={() => setOpen(true)}
      onMouseLeave={() => setOpen(false)}
      onFocus={() => setOpen(true)}
      onBlur={(e) => {
        if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setOpen(false);
      }}
    >
      <div className="relative aspect-[4/4.2] overflow-hidden">
        {member.imageSrc ? (
          <img src={member.imageSrc} alt="" className="size-full object-cover" loading="lazy" />
        ) : (
          <div
            className="relative flex size-full items-center justify-center"
            style={{
              backgroundImage: `radial-gradient(120% 90% at 20% 0%, oklch(0.86 0.09 ${h}), transparent 60%), radial-gradient(100% 90% at 100% 100%, oklch(0.6 0.15 ${(h + 50) % 360}), transparent 65%), linear-gradient(160deg, oklch(0.74 0.11 ${(h + 20) % 360}), oklch(0.5 0.12 ${(h + 70) % 360}))`,
            }}
          >
            <svg viewBox="0 0 200 210" className="absolute inset-x-0 bottom-0 h-[78%] w-full" aria-hidden>
              <circle cx="100" cy="78" r="38" fill="oklch(1 0 0 / 0.22)" />
              <path d="M28 210 C 32 150, 66 126, 100 126 C 134 126, 168 150, 172 210 Z" fill="oklch(1 0 0 / 0.22)" />
            </svg>
            <span
              className="relative font-display text-5xl font-semibold tracking-tight"
              style={{ color: "oklch(1 0 0 / 0.95)", textShadow: "0 2px 18px oklch(0 0 0 / 0.18)" }}
            >
              {initials(member.name)}
            </span>
          </div>
        )}

        <motion.div
          initial={false}
          animate={open ? { y: 0, opacity: 1 } : { y: "100%", opacity: 0 }}
          transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 360, damping: 36 }}
          className="absolute inset-x-0 bottom-0 hidden border-t border-border bg-card/95 p-4 backdrop-blur-md sm:block"
        >
          <p className="text-sm leading-relaxed text-foreground/85">{member.bio}</p>
        </motion.div>
      </div>

      <div className="flex flex-1 flex-col p-4">
        <div className="flex items-start justify-between gap-3">
          <div className="min-w-0">
            <h3 className="truncate font-semibold text-foreground">{member.name}</h3>
            <p className="truncate text-sm text-brand">{member.role}</p>
          </div>
        </div>
        {/* Bio is always visible on touch screens */}
        <p className="mt-3 text-sm leading-relaxed text-muted-foreground sm:hidden">{member.bio}</p>
        <div className="mt-auto flex items-center justify-between gap-3 pt-4">
          {member.location ? <span className="truncate text-xs text-muted-foreground">{member.location}</span> : <span />}
          <SocialLinks member={member} />
        </div>
      </div>
    </motion.li>
  );
}

export function TeamGrid({
  eyebrow = "Our team",
  heading = "Local experts who know every street",
  description = "Bayside Realty is a boutique brokerage of agents who live in the neighborhoods they sell. Hover a card to meet the person behind it.",
  members = DEFAULT_MEMBERS,
  showHiringCard = true,
  hiringHeading = "We're hiring",
  hiringText = "Licensed in Florida and hungry to build something? We offer mentorship, real leads and an 80/20 split.",
  hiringCtaLabel = "See open roles",
  hiringHref = "#",
  openRoles = 3,
  className,
}: TeamGridProps) {
  const reduce = useReducedMotion() ?? false;
  return (
    <section className={cn("relative bg-background py-20 sm:py-28", className)}>
      <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
        <div className="mx-auto max-w-2xl text-center">
          <p className="text-sm font-medium text-brand">{eyebrow}</p>
          <h2 className="mt-3 font-display text-3xl font-semibold tracking-tight text-foreground sm:text-5xl">{heading}</h2>
          <p className="mt-4 text-base leading-relaxed text-muted-foreground sm:text-lg">{description}</p>
        </div>

        <ul className="mt-14 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
          {members.map((m, i) => (
            <MemberCard key={m.name} member={m} index={i} reduce={reduce} />
          ))}

          {showHiringCard && (
            <motion.li
              initial={reduce ? false : { opacity: 0, y: 20 }}
              whileInView={{ opacity: 1, y: 0 }}
              viewport={{ once: true, margin: "-40px 0px" }}
              transition={{ duration: 0.55, delay: (members.length % 4) * 0.07, ease: [0.22, 1, 0.36, 1] }}
              className="group relative min-h-[320px] overflow-hidden rounded-2xl border border-dashed border-foreground/20 bg-muted/40"
            >
              <div
                aria-hidden
                className="pointer-events-none absolute -right-16 -top-16 size-56 rounded-full bg-brand/20 blur-3xl transition-opacity duration-500 group-hover:opacity-100 sm:opacity-60"
              />
              <a
                href={hiringHref}
                className="relative flex h-full flex-col p-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
              >
                <span className="flex size-12 items-center justify-center rounded-2xl border border-border bg-card text-foreground shadow-sm transition-transform duration-500 group-hover:rotate-90">
                  <Plus className="size-5" aria-hidden />
                </span>
                <span className="mt-6 inline-flex w-fit items-center gap-1.5 rounded-full bg-brand/10 px-2.5 py-1 text-xs font-semibold text-brand">
                  <Sparkles className="size-3" aria-hidden />
                  {openRoles} open {openRoles === 1 ? "role" : "roles"}
                </span>
                <span className="mt-3 font-display text-2xl font-semibold tracking-tight text-foreground">{hiringHeading}</span>
                <span className="mt-2 text-sm leading-relaxed text-muted-foreground">{hiringText}</span>
                <span className="mt-auto inline-flex items-center gap-1.5 pt-6 text-sm font-semibold text-foreground">
                  {hiringCtaLabel}
                  <ArrowRight className="size-4 transition-transform group-hover:translate-x-1" aria-hidden />
                </span>
              </a>
            </motion.li>
          )}
        </ul>
      </div>
    </section>
  );
}

export default TeamGrid;
```
