Fazekit

Code

"use client";
import * as React from "react";
import { motion, useReducedMotion, type Variants } from "motion/react";
import { AtSign, Globe, Mail, RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils";

export type TeamMember = {
  name: string;
  role: string;
  bio: string;
  location?: string;
  /** Tailwind gradient classes for the avatar, e.g. "from-sky-500 to-indigo-600". */
  gradient?: string;
  links?: { email?: string; website?: string; handle?: string };
};

export interface TeamGridProps {
  eyebrow?: string;
  title?: string;
  subtitle?: string;
  members?: TeamMember[];
  cta?: { label: string; href: string } | null;
  className?: string;
}

const GRADIENTS = [
  "from-violet-500 via-fuchsia-500 to-pink-500",
  "from-sky-500 via-blue-500 to-indigo-600",
  "from-emerald-400 via-teal-500 to-cyan-600",
  "from-amber-400 via-orange-500 to-rose-500",
  "from-rose-500 via-pink-500 to-fuchsia-600",
  "from-cyan-400 via-sky-500 to-blue-600",
  "from-lime-400 via-emerald-500 to-teal-600",
  "from-indigo-500 via-violet-500 to-purple-600",
];

const DEFAULT_MEMBERS: TeamMember[] = [
  { name: "Ava Lindgren", role: "Co-founder & CEO", location: "Stockholm", bio: "Previously led product at two fintech scale-ups. Obsessed with calm software and long walks.", links: { email: "[email protected]", website: "#", handle: "#" } },
  { name: "Mateo Rivera", role: "Co-founder & CTO", location: "Lisbon", bio: "Built distributed systems for a decade. Writes Rust for fun and bakes sourdough for friends.", links: { email: "[email protected]", website: "#", handle: "#" } },
  { name: "Noor Haddad", role: "Head of Design", location: "Amman", bio: "Designs interfaces that disappear. Former type designer with a soft spot for grids.", links: { email: "[email protected]", website: "#", handle: "#" } },
  { name: "Kai Nakamura", role: "Lead Engineer", location: "Osaka", bio: "Performance nerd who shaved 60% off our bundle. Runs the internal tech talks.", links: { email: "[email protected]", website: "#", handle: "#" } },
  { name: "Zara Okonkwo", role: "Product Manager", location: "Lagos", bio: "Turns fuzzy customer problems into crisp roadmaps. Talks to five users every week.", links: { email: "[email protected]", website: "#", handle: "#" } },
  { name: "Leo Brandt", role: "Customer Success", location: "Berlin", bio: "Our customers' favourite human. Answers tickets faster than most people read them.", links: { email: "[email protected]", website: "#", handle: "#" } },
  { name: "Isla Moreau", role: "Marketing Lead", location: "Montréal", bio: "Storyteller and former journalist. Makes technical launches sound like good news.", links: { email: "[email protected]", website: "#", handle: "#" } },
  { name: "Ravi Menon", role: "Data Scientist", location: "Bengaluru", bio: "Forecasts, experiments and the occasional chess tournament. Loves a good histogram.", links: { email: "[email protected]", website: "#", handle: "#" } },
];

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

const container: Variants = { hidden: {}, show: { transition: { staggerChildren: 0.07, delayChildren: 0.05 } } };
const card: Variants = {
  hidden: { opacity: 0, y: 28, scale: 0.96 },
  show: { opacity: 1, y: 0, scale: 1, transition: { type: "spring", stiffness: 260, damping: 26 } },
};

function MemberCard({ m, index }: { m: TeamMember; index: number }) {
  const reduce = useReducedMotion();
  const [hover, setHover] = React.useState(false);
  const [focusWithin, setFocusWithin] = React.useState(false);
  const [pinned, setPinned] = React.useState(false); // tap/click toggle for touch devices
  const flipped = hover || focusWithin || pinned;
  const gradient = m.gradient ?? GRADIENTS[index % GRADIENTS.length];
  const face = "absolute inset-0 overflow-hidden rounded-3xl [backface-visibility:hidden] [-webkit-backface-visibility:hidden]";

  return (
    <motion.li variants={reduce ? undefined : card} className="[perspective:1200px]">
      <div
        className="relative aspect-[3/4] w-full sm:aspect-[4/5] lg:aspect-[10/11]"
        onPointerEnter={(e) => e.pointerType === "mouse" && setHover(true)}
        onPointerLeave={(e) => e.pointerType === "mouse" && setHover(false)}
        onFocus={(e) => {
          // keyboard focus flips the card; taps are handled by the "Read bio" toggle instead
          if ((e.target as HTMLElement).matches(":focus-visible")) setFocusWithin(true);
        }}
        onBlur={(e) => {
          if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocusWithin(false);
        }}
      >
        <motion.div
          className="relative size-full [transform-style:preserve-3d]"
          initial={false}
          animate={reduce ? { opacity: 1 } : { rotateY: flipped ? 180 : 0 }}
          transition={{ type: "spring", stiffness: 180, damping: 22 }}
        >
          {/* Front */}
          <div className={cn(face, "border bg-card", reduce && flipped && "invisible")}>
            <div className={cn("absolute inset-0 bg-gradient-to-br opacity-90", gradient)} />
            <div aria-hidden className="absolute inset-0 [background-image:radial-gradient(rgb(255_255_255/0.22)_1px,transparent_1px)] [background-size:14px_14px] [mask-image:linear-gradient(to_bottom,#000,transparent_70%)]" />
            <div className="absolute inset-x-0 top-[14%] flex justify-center lg:top-[12%]">
              <span aria-hidden className="grid size-20 place-items-center rounded-full bg-white/20 text-2xl font-semibold tracking-tight text-white shadow-xl ring-1 ring-white/40 backdrop-blur sm:size-24 sm:text-3xl">
                {initials(m.name)}
              </span>
            </div>
            <div className="absolute inset-x-2 bottom-2 rounded-2xl bg-card/95 p-3 backdrop-blur sm:inset-x-3 sm:bottom-3 sm:p-4">
              <h3 className="truncate text-sm font-semibold text-foreground sm:text-base">{m.name}</h3>
              <p className="truncate text-xs text-muted-foreground sm:text-sm">{m.role}</p>
              <button
                type="button"
                onClick={() => setPinned((p) => !p)}
                aria-expanded={flipped}
                className="mt-2 text-xs font-medium text-primary underline-offset-4 hover:underline focus-visible:rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
              >
                Read bio<span className="sr-only"> of {m.name}</span>
              </button>
            </div>
          </div>

          {/* Back */}
          <div
            className={cn(
              face,
              "flex flex-col border bg-card p-4 text-card-foreground sm:p-5",
              reduce ? (flipped ? "visible" : "invisible") : "[transform:rotateY(180deg)]",
            )}
          >
            <div aria-hidden className={cn("absolute -right-10 -top-10 size-32 rounded-full bg-gradient-to-br opacity-25 blur-2xl", gradient)} />
            <div className="relative flex items-center gap-3">
              <span aria-hidden className={cn("grid size-10 shrink-0 place-items-center rounded-full bg-gradient-to-br text-sm font-semibold text-white", gradient)}>
                {initials(m.name)}
              </span>
              <div className="min-w-0">
                <p className="truncate text-sm font-semibold">{m.name}</p>
                {m.location && <p className="truncate text-xs text-muted-foreground">{m.location}</p>}
              </div>
            </div>
            <p className="relative mt-3 truncate text-[11px] font-semibold uppercase tracking-wider text-primary sm:mt-5 sm:text-xs">{m.role}</p>
            <p className="relative mt-1.5 line-clamp-4 text-xs leading-relaxed text-muted-foreground sm:mt-2 sm:line-clamp-none sm:text-sm">{m.bio}</p>
            <div className="relative mt-auto flex items-center gap-1.5 pt-3">
              {m.links?.email && (
                <a href={`mailto:${m.links.email}`} aria-label={`Email ${m.name}`} className="grid size-8 place-items-center rounded-full border bg-background text-muted-foreground transition hover:border-primary/40 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
                  <Mail className="size-3.5" />
                </a>
              )}
              {m.links?.website && (
                <a href={m.links.website} aria-label={`${m.name}'s website`} className="grid size-8 place-items-center rounded-full border bg-background text-muted-foreground transition hover:border-primary/40 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
                  <Globe className="size-3.5" />
                </a>
              )}
              {m.links?.handle && (
                <a href={m.links.handle} aria-label={`${m.name}'s profile`} className="grid size-8 place-items-center rounded-full border bg-background text-muted-foreground transition hover:border-primary/40 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
                  <AtSign className="size-3.5" />
                </a>
              )}
              {pinned && (
                <button
                  type="button"
                  onClick={() => setPinned(false)}
                  aria-label={`Hide bio of ${m.name}`}
                  className="ml-auto grid size-8 place-items-center rounded-full text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                >
                  <RotateCcw className="size-3.5" />
                </button>
              )}
            </div>
          </div>
        </motion.div>
      </div>
    </motion.li>
  );
}

export function TeamGrid({
  eyebrow = "Our team",
  title = "Small team, big ambitions",
  subtitle = "We're a remote-first crew of builders across eight time zones. Hover or focus a card to meet us properly.",
  members = DEFAULT_MEMBERS,
  cta = { label: "We're hiring — see open roles", href: "#careers" },
  className,
}: TeamGridProps) {
  const reduce = useReducedMotion();
  return (
    <section className={cn("relative w-full overflow-hidden bg-background px-4 py-16 sm:px-6 sm:py-20", className)}>
      <div className="mx-auto max-w-6xl">
        <header className="flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
          <div className="max-w-2xl">
            <p className="text-sm font-semibold uppercase tracking-[0.18em] text-primary">{eyebrow}</p>
            <h2 className="mt-3 text-balance text-3xl font-semibold tracking-tight text-foreground sm:text-5xl">{title}</h2>
            <p className="mt-4 text-pretty text-base text-muted-foreground sm:text-lg">{subtitle}</p>
          </div>
          {cta && (
            <a
              href={cta.href}
              className="inline-flex h-11 shrink-0 items-center gap-2 self-start rounded-full border bg-card px-5 text-sm font-semibold text-foreground transition hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:self-auto"
            >
              <span className="relative flex size-2">
                <span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-75 motion-reduce:animate-none" />
                <span className="relative inline-flex size-2 rounded-full bg-emerald-500" />
              </span>
              {cta.label}
            </a>
          )}
        </header>

        <motion.ul
          variants={reduce ? undefined : container}
          initial="hidden"
          whileInView="show"
          viewport={{ once: true, margin: "-60px" }}
          className="mt-12 grid grid-cols-2 gap-3 sm:gap-5 lg:grid-cols-4"
        >
          {members.map((m, i) => (
            <MemberCard key={m.name} m={m} index={i} />
          ))}
        </motion.ul>
      </div>
    </section>
  );
}