"use client";
import { ArrowUpRight, MapPin } from "lucide-react";
import {
AnimatePresence,
LayoutGroup,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import { type ReactNode, useId, useMemo, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/motion/dialog";
import { EASE_OUT, EASE_OUT_CSS, SPRING_LAYOUT } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface TeamSocialLink {
/** Accessible name, e.g. "GitHub". */
label: string;
href: string;
icon: ReactNode;
}
export interface TeamMember {
id: string;
name: string;
role: string;
/** Filter group, e.g. "Engineering". */
department?: string;
bio?: ReactNode;
location?: string;
/** Portrait: an image URL or any node (illustration, gradient). Falls back to initials. */
photo?: string | ReactNode;
/** Background for the initials fallback (any CSS color). */
color?: string;
socials?: TeamSocialLink[];
}
export interface TeamSectionProps {
members: TeamMember[];
eyebrow?: ReactNode;
heading?: ReactNode;
description?: ReactNode;
/** Show department chips. Default true when members span 2+ departments. */
filterable?: boolean;
/** Department order. Defaults to first-seen order in `members`. */
departments?: string[];
allLabel?: string;
/** Controlled active department (`allLabel` or a department). */
department?: string;
defaultDepartment?: string;
onDepartmentChange?: (department: string) => void;
/** Most columns at wide container sizes. Default 4. */
columns?: 2 | 3 | 4;
/** Called when a member's detail panel opens. */
onMemberOpen?: (member: TeamMember) => void;
className?: string;
}
// Static class maps so Tailwind sees every class.
const GRID_COLS: Record<2 | 3 | 4, string> = {
2: "@lg:grid-cols-2",
3: "@lg:grid-cols-3",
4: "@lg:grid-cols-3 @3xl:grid-cols-4",
};
const STAGGER = 0.06;
const CHART_VARS = [1, 2, 3, 4, 5].map((n) => `var(--chart-${n})`);
/** Deterministic fallback color from the name, so SSR and client agree. */
function colorFor(member: TeamMember) {
if (member.color) return member.color;
let hash = 0;
for (const ch of member.name) hash = (hash * 31 + ch.charCodeAt(0)) | 0;
return CHART_VARS[Math.abs(hash) % CHART_VARS.length];
}
function initials(name: string) {
const parts = name.trim().split(/\s+/);
const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : "";
return ((parts[0]?.[0] ?? "") + last).toUpperCase();
}
function Portrait({ member, className }: { member: TeamMember; className?: string }) {
if (typeof member.photo === "string") {
return (
// biome-ignore lint/performance/noImgElement: library component, framework-agnostic.
);
}
if (member.photo) {
return
{member.photo}
;
}
const color = colorFor(member);
return (
{initials(member.name)}
);
}
function SocialLinks({
socials,
size = "sm",
className,
}: {
socials: TeamSocialLink[];
size?: "sm" | "md";
className?: string;
}) {
return (
);
}
/**
* Team page section: a header (eyebrow, heading, description), department
* filter chips with a gliding pill, and a grid of member cards that stagger in
* on view. Hovering or focusing a card slides up its bio and social links;
* clicking a member opens a detail dialog with the full bio and links. The
* grid reflows with spring layout when the filter changes and picks its
* column count from its own container width.
*/
export function TeamSection({
members,
eyebrow = "Team",
heading = "The people behind the work",
description,
filterable,
departments: departmentsProp,
allLabel = "Everyone",
department: departmentProp,
defaultDepartment,
onDepartmentChange,
columns = 4,
onMemberOpen,
className,
}: TeamSectionProps) {
const reduce = useReducedMotion() ?? false;
const canHover = useHoverCapable();
const uid = useId();
const departments = useMemo(() => {
if (departmentsProp) return departmentsProp;
const seen: string[] = [];
for (const member of members) {
if (member.department && !seen.includes(member.department)) seen.push(member.department);
}
return seen;
}, [members, departmentsProp]);
const showFilters = filterable ?? departments.length > 1;
const [internal, setInternal] = useState(defaultDepartment ?? allLabel);
const active = departmentProp ?? internal;
const setActive = (next: string) => {
if (departmentProp === undefined) setInternal(next);
onDepartmentChange?.(next);
};
const visible = useMemo(
() => (active === allLabel ? members : members.filter((m) => m.department === active)),
[members, active, allLabel],
);
// Keep the last member while the dialog plays its exit.
const [selected, setSelected] = useState(null);
const [open, setOpen] = useState(false);
const openMember = (member: TeamMember) => {
setSelected(member);
setOpen(true);
onMemberOpen?.(member);
};
const layoutTransition = reduce ? { duration: 0 } : SPRING_LAYOUT;
const item: Variants = {
hidden: { opacity: 0, y: reduce ? 0 : 16 },
show: { opacity: 1, y: 0, transition: { duration: 0.45, ease: EASE_OUT } },
exit: {
opacity: 0,
scale: reduce ? 1 : 0.96,
transition: { duration: 0.15, ease: EASE_OUT },
},
};
return (
{eyebrow ? (
{eyebrow}
) : null}
{heading ? (
{heading}
) : null}
{description ? (
{description}
) : null}
{showFilters ? (
) : null}
{visible.map((member) => (
// Layout animation is the point: cards glide into the gaps
// left by members filtered out.
openMember(member)}
/>
))}
);
}
function MemberCard({
member,
canHover,
reduce,
onOpen,
}: {
member: TeamMember;
canHover: boolean;
reduce: boolean;
onOpen: () => void;
}) {
const socials = member.socials ?? [];
const reveal = canHover && (member.bio || socials.length > 0);
return (
{reveal ? (
// Hover/focus reveal. Sits above the stretched button (z-20) so the
// social links stay clickable; the panel itself lets clicks through.
{member.bio ? (
{member.bio}
) : null}
{socials.length > 0 ? (
) : null}
) : null}
{/* Stretched button: its ::after covers the card, so the whole
card opens the detail panel while staying one tab stop. */}
{member.role}
{!canHover && socials.length > 0 ? (
) : null}
);
}
function MemberDetail({ member }: { member: TeamMember }) {
const socials = member.socials ?? [];
return (
<>
{member.name}
{member.role}
{member.department ? (
{member.department}
) : null}
{member.location ? (
{member.location}
) : null}
{member.bio ? (
{member.bio}
) : null}
{socials.length > 0 ? (
) : null}
>
);
}