Profile Team
Original · freeteam member profile grid
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/profile-team.json"use client";
import { useCallback, useId, useMemo, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Dept = "engineering" | "design" | "research" | "growth";
type Status = "available" | "heads-down" | "away";
type Glyph = "site" | "github" | "linkedin" | "mail";
type ProfileLink = {
label: string;
href: string;
glyph: Glyph;
};
type Member = {
id: string;
name: string;
pronouns: string;
role: string;
dept: Dept;
location: string;
offset: string;
img: string;
tagline: string;
bio: string;
focus: string[];
since: string;
status: Status;
links: ProfileLink[];
};
const DEPT_LABEL: Record<Dept, string> = {
engineering: "Engineering",
design: "Design",
research: "Research",
growth: "Growth",
};
const STATUS_LABEL: Record<Status, string> = {
available: "Available",
"heads-down": "Heads-down",
away: "Away until Monday",
};
const STATUS_DOT: Record<Status, string> = {
available: "bg-emerald-500",
"heads-down": "bg-amber-500",
away: "bg-zinc-400 dark:bg-zinc-500",
};
const STATUS_TEXT: Record<Status, string> = {
available: "text-emerald-700 dark:text-emerald-400",
"heads-down": "text-amber-700 dark:text-amber-400",
away: "text-zinc-500 dark:text-zinc-400",
};
const MEMBERS: Member[] = [
{
id: "iris-vantol",
name: "Iris Vantol",
pronouns: "she/her",
role: "Principal Engineer, Runtime",
dept: "engineering",
location: "Rotterdam, NL",
offset: "UTC+1",
img: "/img/gallery/g03.webp",
tagline: "Makes the render loop cheaper.",
bio: "Owns the diffing layer. Cut our median frame budget from 14ms to 6ms mostly by deleting code, which is her preferred way to fix anything. Wrote the RFC that retired our custom scheduler after two years of everyone quietly agreeing it was a mistake.",
focus: ["Rendering", "Profiling", "RFC review"],
since: "Joined March 2019",
status: "available",
links: [
{ label: "Iris on GitHub", href: "#", glyph: "github" },
{ label: "Iris's site", href: "#", glyph: "site" },
{ label: "Email Iris", href: "#", glyph: "mail" },
],
},
{
id: "dmitri-okonkwo",
name: "Dmitri Okonkwo",
pronouns: "he/him",
role: "Staff Product Designer",
dept: "design",
location: "Lagos, NG",
offset: "UTC+1",
img: "/img/gallery/g11.webp",
tagline: "Draws the thing before we build the thing.",
bio: "Runs Thursday critique and keeps the component library honest: 96 components, zero orphans, every one mapped to a token. Spent eight years in print before this and still thinks in baseline grids, which turns out to be an advantage.",
focus: ["Design systems", "Critique", "Typography"],
since: "Joined August 2020",
status: "heads-down",
links: [
{ label: "Dmitri's portfolio", href: "#", glyph: "site" },
{ label: "Dmitri on LinkedIn", href: "#", glyph: "linkedin" },
{ label: "Email Dmitri", href: "#", glyph: "mail" },
],
},
{
id: "hana-sorensen",
name: "Hana Sørensen",
pronouns: "she/her",
role: "Research Lead",
dept: "research",
location: "Copenhagen, DK",
offset: "UTC+1",
img: "/img/gallery/g19.webp",
tagline: "Talks to forty customers a quarter.",
bio: "Runs the interview program and the repository behind it. Every claim in a spec here traces back to a clip she tagged, which is why specs stopped being arguments. Has strong, well-earned opinions about leading questions.",
focus: ["Interviews", "Synthesis", "Usability testing"],
since: "Joined January 2021",
status: "available",
links: [
{ label: "Hana on LinkedIn", href: "#", glyph: "linkedin" },
{ label: "Email Hana", href: "#", glyph: "mail" },
],
},
{
id: "marco-delaney",
name: "Marco Delaney",
pronouns: "he/him",
role: "Engineering Manager, Platform",
dept: "engineering",
location: "Dublin, IE",
offset: "UTC+0",
img: "/img/gallery/g25.webp",
tagline: "Unblocks people, ships less code.",
bio: "Manages the six engineers who keep billing, auth, and the job queue boring. Wrote our incident review template and reads every postmortem twice — once for the fix, once for what we learned about ourselves.",
focus: ["Reliability", "Hiring", "Incident review"],
since: "Joined May 2018",
status: "away",
links: [
{ label: "Marco on LinkedIn", href: "#", glyph: "linkedin" },
{ label: "Email Marco", href: "#", glyph: "mail" },
],
},
{
id: "aisha-rahmani",
name: "Aisha Rahmani",
pronouns: "she/her",
role: "Growth Engineer",
dept: "growth",
location: "Toronto, CA",
offset: "UTC−5",
img: "/img/gallery/g08.webp",
tagline: "Half marketer, half engineer, no patience for vibes.",
bio: "Built the experiment framework and the dashboard nobody argues with. Shipped 31 tests last year: nine won. She will tell you about the twenty-two that didn't before she mentions the nine.",
focus: ["Experimentation", "Analytics", "Landing pages"],
since: "Joined October 2021",
status: "available",
links: [
{ label: "Aisha on GitHub", href: "#", glyph: "github" },
{ label: "Aisha's site", href: "#", glyph: "site" },
{ label: "Email Aisha", href: "#", glyph: "mail" },
],
},
{
id: "tobias-lind",
name: "Tobias Lind",
pronouns: "they/them",
role: "Senior Motion Designer",
dept: "design",
location: "Berlin, DE",
offset: "UTC+1",
img: "/img/gallery/g14.webp",
tagline: "Everything moves at 240 milliseconds.",
bio: "Owns the motion spec — durations, easing curves, and the reduced-motion rules that actually survive the trip into code. Prototypes in real components, never in video, because video never has to handle a slow network.",
focus: ["Motion", "Prototyping", "Accessibility"],
since: "Joined February 2022",
status: "heads-down",
links: [
{ label: "Tobias's reel", href: "#", glyph: "site" },
{ label: "Email Tobias", href: "#", glyph: "mail" },
],
},
{
id: "nadia-costa",
name: "Nadia Costa",
pronouns: "she/her",
role: "Quantitative Researcher",
dept: "research",
location: "Lisbon, PT",
offset: "UTC+0",
img: "/img/gallery/g30.webp",
tagline: "Counts what the interviews can't.",
bio: "Pairs survey design with product telemetry so we stop guessing at sample size. Rebuilt our satisfaction instrument after proving the old one leaked bias in the first question — the rewrite moved the score four points and none of it was real.",
focus: ["Surveys", "Telemetry", "Statistics"],
since: "Joined June 2022",
status: "available",
links: [
{ label: "Nadia's writing", href: "#", glyph: "site" },
{ label: "Nadia on LinkedIn", href: "#", glyph: "linkedin" },
],
},
{
id: "owen-brackett",
name: "Owen Brackett",
pronouns: "he/him",
role: "Lifecycle Marketer",
dept: "growth",
location: "Melbourne, AU",
offset: "UTC+11",
img: "/img/gallery/g22.webp",
tagline: "Writes the emails you don't unsubscribe from.",
bio: "Owns onboarding, activation, and win-back. Cut the welcome sequence from nine emails to four and activation went up eleven percent, a fact he has worked into roughly every conversation since.",
focus: ["Email", "Onboarding", "Copywriting"],
since: "Joined September 2023",
status: "heads-down",
links: [
{ label: "Owen on LinkedIn", href: "#", glyph: "linkedin" },
{ label: "Email Owen", href: "#", glyph: "mail" },
],
},
{
id: "kenji-aroyo",
name: "Kenji Aroyo",
pronouns: "he/him",
role: "Security Engineer",
dept: "engineering",
location: "Osaka, JP",
offset: "UTC+9",
img: "/img/gallery/g05.webp",
tagline: "Assumes it's already broken.",
bio: "Runs bug bounty triage and the quarterly access review nobody looks forward to. Found the token replay bug in our own SDK three days before a researcher did, which he calls luck and everyone else calls Tuesday.",
focus: ["AppSec", "Threat modelling", "Bug bounty"],
since: "Joined April 2020",
status: "available",
links: [
{ label: "Kenji on GitHub", href: "#", glyph: "github" },
{ label: "Email Kenji", href: "#", glyph: "mail" },
],
},
];
const FILTERS: { id: Dept | "all"; label: string }[] = [
{ id: "all", label: "Everyone" },
{ id: "engineering", label: "Engineering" },
{ id: "design", label: "Design" },
{ id: "research", label: "Research" },
{ id: "growth", label: "Growth" },
];
function Icon({ glyph }: { glyph: Glyph }) {
const common = {
viewBox: "0 0 24 24",
"aria-hidden": true,
focusable: false as const,
className: "h-4 w-4",
};
if (glyph === "github") {
return (
<svg {...common} fill="currentColor">
<path d="M12 2C6.48 2 2 6.58 2 12.25c0 4.53 2.87 8.37 6.84 9.73.5.1.68-.22.68-.49 0-.24-.01-.87-.01-1.71-2.78.62-3.37-1.38-3.37-1.38-.45-1.19-1.11-1.5-1.11-1.5-.91-.64.07-.63.07-.63 1 .07 1.53 1.06 1.53 1.06.89 1.57 2.34 1.12 2.91.86.09-.66.35-1.12.63-1.38-2.22-.26-4.56-1.14-4.56-5.06 0-1.12.39-2.03 1.03-2.75-.1-.26-.45-1.3.1-2.71 0 0 .84-.28 2.75 1.05a9.3 9.3 0 0 1 2.5-.34c.85 0 1.71.12 2.5.34 1.91-1.33 2.75-1.05 2.75-1.05.55 1.41.2 2.45.1 2.71.64.72 1.03 1.63 1.03 2.75 0 3.93-2.35 4.79-4.58 5.05.36.32.68.94.68 1.9 0 1.37-.01 2.48-.01 2.82 0 .27.18.6.69.49A10.06 10.06 0 0 0 22 12.25C22 6.58 17.52 2 12 2Z" />
</svg>
);
}
if (glyph === "linkedin") {
return (
<svg {...common} fill="currentColor">
<path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5ZM3 9.5h4v11H3v-11Zm7 0h3.83v1.5h.05c.53-.95 1.83-1.96 3.77-1.96 4.03 0 4.78 2.5 4.78 5.76v5.7h-4v-5.06c0-1.2-.02-2.75-1.75-2.75-1.76 0-2.03 1.3-2.03 2.66v5.15h-4v-11Z" />
</svg>
);
}
if (glyph === "mail") {
return (
<svg {...common} fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round">
<rect x="2.5" y="4.5" width="19" height="15" rx="2.5" />
<path d="m3.5 6.5 7.4 5.6a2 2 0 0 0 2.2 0l7.4-5.6" />
</svg>
);
}
return (
<svg {...common} fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="9.25" />
<path d="M2.9 12h18.2M12 2.75c2.3 2.5 3.45 5.58 3.45 9.25S14.3 18.75 12 21.25c-2.3-2.5-3.45-5.58-3.45-9.25S9.7 5.25 12 2.75Z" />
</svg>
);
}
export default function ProfileTeam() {
const reduced = useReducedMotion();
const uid = useId();
const detailId = `${uid}-detail`;
const searchId = `${uid}-search`;
const [dept, setDept] = useState<Dept | "all">("all");
const [query, setQuery] = useState<string>("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const cardRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const searchRef = useRef<HTMLInputElement | null>(null);
const setCardRef = useCallback((id: string, node: HTMLButtonElement | null) => {
if (node) cardRefs.current.set(id, node);
else cardRefs.current.delete(id);
}, []);
const counts = useMemo(() => {
const base: Record<string, number> = { all: MEMBERS.length };
for (const m of MEMBERS) base[m.dept] = (base[m.dept] ?? 0) + 1;
return base;
}, []);
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
return MEMBERS.filter((m) => {
if (dept !== "all" && m.dept !== dept) return false;
if (!q) return true;
const hay = `${m.name} ${m.role} ${m.location} ${m.tagline} ${m.focus.join(" ")} ${DEPT_LABEL[m.dept]}`;
return hay.toLowerCase().includes(q);
});
}, [dept, query]);
const selected = useMemo(
() => visible.find((m) => m.id === selectedId) ?? null,
[visible, selectedId],
);
const onGridKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLButtonElement>, index: number) => {
const keys = ["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp", "Home", "End"];
if (!keys.includes(event.key)) return;
event.preventDefault();
let next = index;
if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % visible.length;
if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index - 1 + visible.length) % visible.length;
if (event.key === "Home") next = 0;
if (event.key === "End") next = visible.length - 1;
const target = visible[next];
if (target) cardRefs.current.get(target.id)?.focus();
},
[visible],
);
const clearFilters = useCallback(() => {
setQuery("");
setDept("all");
searchRef.current?.focus();
}, []);
const ease = [0.22, 1, 0.36, 1] as const;
return (
<section className="relative w-full overflow-hidden bg-white px-5 py-20 text-slate-900 sm:px-8 sm:py-28 dark:bg-zinc-950 dark:text-slate-100">
<style>{`
@keyframes pteam-drift {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); opacity: 0.55; }
50% { transform: translate3d(3%, -4%, 0) scale(1.12); opacity: 0.8; }
}
@keyframes pteam-halo {
0% { transform: scale(1); opacity: 0.6; }
70%, 100% { transform: scale(2.4); opacity: 0; }
}
.pteam-drift { animation: pteam-drift 22s ease-in-out infinite; }
.pteam-halo { animation: pteam-halo 2.6s cubic-bezier(0.22,1,0.36,1) infinite; }
.pteam-photo { transition: transform 600ms cubic-bezier(0.22,1,0.36,1), filter 400ms ease; }
.pteam-card:hover .pteam-photo,
.pteam-card:focus-visible .pteam-photo { transform: scale(1.06); }
@media (prefers-reduced-motion: reduce) {
.pteam-drift, .pteam-halo { animation: none; }
.pteam-photo { transition: none; }
.pteam-card:hover .pteam-photo,
.pteam-card:focus-visible .pteam-photo { transform: none; }
}
`}</style>
<div
aria-hidden="true"
className="pteam-drift pointer-events-none absolute -top-40 left-1/2 h-[30rem] w-[46rem] -translate-x-1/2 rounded-full bg-[radial-gradient(closest-side,rgba(99,102,241,0.18),transparent)] blur-2xl dark:bg-[radial-gradient(closest-side,rgba(129,140,248,0.22),transparent)]"
/>
<div className="relative mx-auto w-full max-w-6xl">
<header className="max-w-2xl">
<p className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-medium tracking-wide text-slate-600 uppercase dark:border-zinc-800 dark:bg-zinc-900 dark:text-slate-400">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Nine people, six timezones
</p>
<h2 className="mt-5 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
The people who actually build it
</h2>
<p className="mt-4 text-base leading-relaxed text-slate-600 dark:text-slate-400">
No stock headshots of a leadership team you'll never email. This is everyone who touches
the product — what they own, where they are, and how to reach them directly.
</p>
</header>
<div className="mt-10 flex flex-col gap-4 border-b border-slate-200 pb-6 lg:flex-row lg:items-center lg:justify-between dark:border-zinc-800">
<div role="group" aria-label="Filter team by department" className="flex flex-wrap gap-2">
{FILTERS.map((f) => {
const active = dept === f.id;
return (
<button
key={f.id}
type="button"
aria-pressed={active}
onClick={() => setDept(f.id)}
className={[
"inline-flex items-center gap-2 rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors",
"focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:outline-none dark:focus-visible:ring-offset-zinc-950",
active
? "border-indigo-600 bg-indigo-600 text-white dark:border-indigo-500 dark:bg-indigo-500"
: "border-slate-200 bg-white text-slate-700 hover:border-slate-300 hover:bg-slate-50 dark:border-zinc-800 dark:bg-zinc-900 dark:text-slate-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800",
].join(" ")}
>
{f.label}
<span
className={[
"tabular-nums",
active ? "text-indigo-100" : "text-slate-400 dark:text-slate-500",
].join(" ")}
>
{counts[f.id] ?? 0}
</span>
</button>
);
})}
</div>
<div className="relative w-full lg:w-72">
<label htmlFor={searchId} className="sr-only">
Search the team by name, role, or skill
</label>
<svg
aria-hidden="true"
focusable="false"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-slate-400 dark:text-slate-500"
>
<circle cx="11" cy="11" r="6.5" />
<path d="m16 16 4.5 4.5" />
</svg>
<input
id={searchId}
ref={searchRef}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search name, role, skill…"
className="w-full rounded-lg border border-slate-200 bg-white py-2 pr-3 pl-9 text-sm text-slate-900 placeholder:text-slate-400 focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-indigo-500/40 focus-visible:outline-none dark:border-zinc-800 dark:bg-zinc-900 dark:text-slate-100 dark:placeholder:text-slate-500"
/>
</div>
</div>
<p aria-live="polite" className="sr-only">
{visible.length} {visible.length === 1 ? "person" : "people"} shown
</p>
{visible.length === 0 ? (
<div className="mt-14 rounded-2xl border border-dashed border-slate-300 px-6 py-14 text-center dark:border-zinc-700">
<p className="text-base font-medium text-slate-800 dark:text-slate-200">
Nobody here matches “{query.trim()}”.
</p>
<p className="mx-auto mt-2 max-w-sm text-sm text-slate-500 dark:text-slate-400">
We're a team of nine, so the search runs out fast. Try “rendering”, “email”, or a
city name.
</p>
<button
type="button"
onClick={clearFilters}
className="mt-6 inline-flex items-center rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-slate-700 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:outline-none dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-white dark:focus-visible:ring-offset-zinc-950"
>
Reset filters
</button>
</div>
) : (
<ul className="mt-10 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{visible.map((m, i) => {
const isOpen = selectedId === m.id;
return (
<motion.li
key={m.id}
layout={!reduced}
initial={reduced ? false : { opacity: 0, y: 14 }}
whileInView={reduced ? undefined : { opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={{ duration: 0.5, delay: reduced ? 0 : Math.min(i, 5) * 0.06, ease }}
>
<button
ref={(node) => setCardRef(m.id, node)}
type="button"
onKeyDown={(e) => onGridKeyDown(e, i)}
onClick={() => setSelectedId(isOpen ? null : m.id)}
aria-expanded={isOpen}
aria-controls={detailId}
className={[
"pteam-card group flex h-full w-full items-start gap-4 rounded-2xl border p-4 text-left transition-colors",
"focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:outline-none dark:focus-visible:ring-offset-zinc-950",
isOpen
? "border-indigo-500 bg-indigo-50/60 dark:border-indigo-500 dark:bg-indigo-500/10"
: "border-slate-200 bg-white hover:border-slate-300 hover:bg-slate-50 dark:border-zinc-800 dark:bg-zinc-900/60 dark:hover:border-zinc-700 dark:hover:bg-zinc-900",
].join(" ")}
>
<span className="relative shrink-0 overflow-hidden rounded-xl bg-slate-100 dark:bg-zinc-800">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={m.img}
alt={`Portrait of ${m.name}`}
loading="lazy"
draggable={false}
className="pteam-photo h-16 w-16 object-cover"
/>
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2">
<span className="truncate text-[15px] font-semibold text-slate-900 dark:text-slate-100">
{m.name}
</span>
<span className="text-xs text-slate-400 dark:text-slate-500">{m.pronouns}</span>
</span>
<span className="mt-0.5 block truncate text-sm text-slate-600 dark:text-slate-400">
{m.role}
</span>
<span className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1.5">
<span className="inline-flex items-center rounded-md bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600 dark:bg-zinc-800 dark:text-slate-300">
{DEPT_LABEL[m.dept]}
</span>
<span
className={[
"inline-flex items-center gap-1.5 text-[11px] font-medium",
STATUS_TEXT[m.status],
].join(" ")}
>
<span className="relative flex h-1.5 w-1.5">
{m.status === "available" && (
<span className="pteam-halo absolute inset-0 rounded-full bg-emerald-500" />
)}
<span className={["relative h-1.5 w-1.5 rounded-full", STATUS_DOT[m.status]].join(" ")} />
</span>
{STATUS_LABEL[m.status]}
</span>
</span>
</span>
<svg
aria-hidden="true"
focusable="false"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
className={[
"mt-1 h-4 w-4 shrink-0 transition-transform",
isOpen
? "rotate-180 text-indigo-600 dark:text-indigo-400"
: "text-slate-400 group-hover:text-slate-600 dark:text-slate-500 dark:group-hover:text-slate-300",
].join(" ")}
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
</motion.li>
);
})}
</ul>
)}
<div id={detailId} role="region" aria-label="Selected team member">
<AnimatePresence initial={false} mode="wait">
{selected && (
<motion.article
key={selected.id}
initial={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }}
animate={reduced ? { opacity: 1 } : { opacity: 1, height: "auto" }}
exit={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }}
transition={{ duration: reduced ? 0.15 : 0.42, ease }}
className="overflow-hidden"
>
<div className="mt-6 rounded-2xl border border-slate-200 bg-slate-50/70 p-6 sm:p-8 dark:border-zinc-800 dark:bg-zinc-900/60">
<div className="flex flex-col gap-8 md:flex-row">
<div className="shrink-0">
<span className="block overflow-hidden rounded-2xl bg-slate-100 dark:bg-zinc-800">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={selected.img}
alt={`Portrait of ${selected.name}`}
loading="lazy"
draggable={false}
className="h-40 w-40 object-cover"
/>
</span>
<ul className="mt-4 flex flex-wrap gap-2">
{selected.links.map((l) => (
<li key={l.label}>
<a
href={l.href}
target="_blank"
rel="noopener"
aria-label={l.label}
title={l.label}
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600 transition-colors hover:border-slate-300 hover:text-slate-900 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:outline-none dark:border-zinc-800 dark:bg-zinc-900 dark:text-slate-400 dark:hover:border-zinc-700 dark:hover:text-slate-100 dark:focus-visible:ring-offset-zinc-950"
>
<Icon glyph={l.glyph} />
</a>
</li>
))}
</ul>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-xl font-semibold tracking-tight text-slate-900 dark:text-slate-100">
{selected.name}{" "}
<span className="text-sm font-normal text-slate-400 dark:text-slate-500">
{selected.pronouns}
</span>
</h3>
<p className="mt-1 text-sm font-medium text-indigo-700 dark:text-indigo-400">
{selected.role}
</p>
</div>
<button
type="button"
onClick={() => {
const id = selected.id;
setSelectedId(null);
cardRefs.current.get(id)?.focus();
}}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-slate-500 transition-colors hover:bg-slate-200 hover:text-slate-900 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:outline-none dark:text-slate-400 dark:hover:bg-zinc-800 dark:hover:text-slate-100 dark:focus-visible:ring-offset-zinc-950"
>
<span className="sr-only">Close {selected.name}'s profile</span>
<svg
aria-hidden="true"
focusable="false"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
className="h-4 w-4"
>
<path d="m6 6 12 12M18 6 6 18" />
</svg>
</button>
</div>
<p className="mt-4 text-[15px] leading-relaxed font-medium text-slate-800 dark:text-slate-200">
“{selected.tagline}”
</p>
<p className="mt-3 text-[15px] leading-relaxed text-slate-600 dark:text-slate-400">
{selected.bio}
</p>
<ul className="mt-5 flex flex-wrap gap-2">
{selected.focus.map((f) => (
<li
key={f}
className="rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-medium text-slate-700 dark:border-zinc-800 dark:bg-zinc-900 dark:text-slate-300"
>
{f}
</li>
))}
</ul>
<dl className="mt-6 grid grid-cols-2 gap-x-6 gap-y-4 border-t border-slate-200 pt-5 sm:grid-cols-4 dark:border-zinc-800">
<div>
<dt className="text-[11px] font-medium tracking-wide text-slate-400 uppercase dark:text-slate-500">
Based in
</dt>
<dd className="mt-1 text-sm text-slate-800 dark:text-slate-200">
{selected.location}
</dd>
</div>
<div>
<dt className="text-[11px] font-medium tracking-wide text-slate-400 uppercase dark:text-slate-500">
Timezone
</dt>
<dd className="mt-1 text-sm tabular-nums text-slate-800 dark:text-slate-200">
{selected.offset}
</dd>
</div>
<div>
<dt className="text-[11px] font-medium tracking-wide text-slate-400 uppercase dark:text-slate-500">
Team
</dt>
<dd className="mt-1 text-sm text-slate-800 dark:text-slate-200">
{DEPT_LABEL[selected.dept]}
</dd>
</div>
<div>
<dt className="text-[11px] font-medium tracking-wide text-slate-400 uppercase dark:text-slate-500">
Tenure
</dt>
<dd className="mt-1 text-sm text-slate-800 dark:text-slate-200">
{selected.since}
</dd>
</div>
</dl>
</div>
</div>
</div>
</motion.article>
)}
</AnimatePresence>
</div>
<p className="mt-8 text-sm text-slate-500 dark:text-slate-400">
Select a card to read the full profile — or use the arrow keys to move through the roster.
We're hiring two more engineers and a researcher this year.
</p>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Profile Header
Originalprofile header with cover and avatar

Profile Settings
Originalaccount settings panel

Profile User Card
Originalcompact user profile card

Profile Stats
Originalprofile with stats and badges

Profile Edit
Originaledit profile form

Profile Social
Originalsocial profile with follow

Profile Tabs
Originalprofile page with tabbed content

Profile Bio
Originalbio card with links

Profile Cover
Originallarge cover profile hero

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

