Component library
Back to catalog

Team hover cards

Centred eyebrow and heading over three portrait cards with a white name strip and a navy share icon; on hover the photo zooms to 1.05 and the icon gives way to a pill of social links. The same reveal runs on keyboard focus.

teampeoplecardshoversocialappearlogistics

Open preview in a new tab

Use this component

Three ways to get Team hover cards into one of your own projects. The first two carry the component's files with them, so nothing has to fetch this page.

Start here

Placement prompt

The whole thing: what to do, where it goes, what must not change, and every one of the component's 4 files appended underneath with the path each belongs at. Paste it into Claude Code inside the destination project — it fetches nothing and needs nothing else.

Read it first — the files are added underneath when you copy
Add Team hover cards from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/team-hover-cards/

Place it in:
[ENTER PAGE, FILE, OR ROUTE]

Position:
[ENTER WHERE IT SHOULD APPEAR]

Every file you need is appended below, each fenced with the path it belongs at. Write them there, relative to the project root. Do not import anything from the deployed library at runtime, and do not fetch the library page — the files below are the source of truth.

Adapt its demo content, links, colors, typography, and images to this project through documented props where possible. Preserve its responsive behavior, accessibility, interaction logic, reduced-motion behavior, and support for multiple instances. Avoid changing unrelated project code.

Test it at desktop, tablet, and mobile widths. Run the project's available check and build commands. Report the files changed and any differences from the library version. Do not commit or deploy.

Install kit

The 4 files this component needs and nothing else, each fenced with the path it belongs at, followed by what the library's own verification covered and what it did not. No instructions attached — for placing it yourself, or handing it to something that has its own.

Read it first
Install kit: "Team hover cards"
Library page: https://astro.baysixmedia.com/components/team-hover-cards/
Files: 4 required, 1 supporting

Write each block below to the path named in its header, relative to the project root. A block marked (required) is one the component does not work without; (supporting) is documentation. Nothing here needs downloading except a block that says so in as many words.

===== FILE (required): src/components/TeamHoverCards.astro =====
---
/**
 * TeamHoverCards
 *
 * A light surface section: a centred orange eyebrow between two short lines,
 * a big navy heading, and three portrait cards. Each card is a pale pink
 * frame around a rounded photo, with a white name / role strip over the
 * photo's bottom edge and a navy round "share" icon on its right.
 *
 * Hover (pointer devices, 810px and up), as measured: the photo scales to
 * 1.05; the navy share icon fades out and a white pill of social links fades
 * in, rising from the icon, capped by an orange copy of the icon. The same
 * reveal runs on :focus-within, so the social links are reachable by
 * keyboard. On a phone, and on touch screens that cannot hover, the links are
 * a plain row under the name instead, as on the reference phone layout.
 *
 * Appear on scroll: the title block and the cards rise 40px, staggered 0.1s.
 * Reads --tg-* design tokens from any ancestor, with fallbacks.
 */

export type TeamSocialNetwork = "instagram" | "x" | "facebook" | "linkedin";

export interface TeamSocialLink {
	network: TeamSocialNetwork;
	href: string;
}

export interface TeamMember {
	name: string;
	role?: string;
	/** Profile page. The name (and the photo) link to it when set. */
	href?: string;
	/** `src: ""` renders the labelled placeholder. */
	image?: { src?: string; alt?: string };
	/** `[]` drops the share icon and the social links. */
	socials?: TeamSocialLink[];
}

export interface TeamHoverCardsProps {
	eyebrow?: string;
	heading?: string;
	headingLevel?: "h2" | "h3";
	/** Level of each name. Defaults to one below the heading. */
	memberHeadingLevel?: "h3" | "h4";
	members?: TeamMember[];
	/** The section surface. */
	background?: string;
	fontStylesheet?: string;
	class?: string;
	id?: string;
}

interface Props extends TeamHoverCardsProps {}

/*
 * No built-in people: the section draws only the members you pass, and a
 * member without `socials` gets none. The component never invents a
 * person or a profile; the catalog demo passes its own.
 */
const {
	eyebrow = "Our People",
	heading = "",
	headingLevel = "h2",
	memberHeadingLevel,
	members = [],
	background,
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=DM+Sans:wght@400;500;600&display=swap",
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;
const NameHeading = memberHeadingLevel ?? (headingLevel === "h2" ? "h3" : "h4");

const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");

const LABELS: Record<TeamSocialNetwork, string> = {
	instagram: "Instagram",
	x: "X",
	facebook: "Facebook",
	linkedin: "LinkedIn",
};

const cards = members.map((m) => {
	const image = { src: "", alt: "", ...(m.image ?? {}) };
	return {
		...m,
		image,
		href: m.href ? safeHref(m.href) : undefined,
		socials: (m.socials ?? []).map((s) => ({ ...s, href: safeHref(s.href) })),
	};
});

/* Measured slot: 352 × 414 at desktop (~0.85:1); the files are 704 × 828. */
const IMAGE_SPEC = "~0.85:1 · 704 × 828 px";
---

{cards.length > 0 && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{cards.length > 0 && (
<section
	class:list={["thc", className]}
	id={id}
	style={background ? `--thc-bg: ${background};` : undefined}
	data-thc-root
>
	<div class="thc-inner">
		{(eyebrow || heading) && (
		<div class="thc-title" data-thc-rise>
			{
				eyebrow && (
					<p class="thc-eyebrow">
						<span class="thc-eyebrow-line" aria-hidden="true" />
						{eyebrow}
						<span class="thc-eyebrow-line" aria-hidden="true" />
					</p>
				)
			}
			{heading && <Heading class="thc-heading">{heading}</Heading>}
		</div>
		)}

		<ul class="thc-grid" role="list">
			{
				cards.map((card, i) => (
					<li class="thc-card" data-thc-rise style={`--thc-i: ${i};`}>
						<div class="thc-media">
							{card.image.src ? (
								card.href ? (
									<a class="thc-media-link" href={card.href} tabindex="-1" aria-hidden="true">
										<img src={card.image.src} alt="" width="704" height="828" loading="lazy" decoding="async" />
									</a>
								) : (
									<img src={card.image.src} alt={card.image.alt} width="704" height="828" loading="lazy" decoding="async" />
								)
							) : (
								<div class="thc-ph" role="img" aria-label={`Image placeholder: portrait of ${card.name}`}>
									<div class="thc-ph-text" aria-hidden="true">
										<span class="thc-ph-label">Image</span>
										<span class="thc-ph-hint">Portrait, head and shoulders</span>
										<span class="thc-ph-spec">{IMAGE_SPEC}</span>
									</div>
								</div>
							)}
						</div>
						<div class="thc-strip">
							<div class="thc-info">
								<NameHeading class="thc-name">
									{card.href ? <a href={card.href}>{card.name}</a> : card.name}
								</NameHeading>
								{card.role && <p class="thc-role">{card.role}</p>}
							</div>
							{card.socials.length > 0 && (
								<div class="thc-share">
									<span class="thc-share-icon" aria-hidden="true">
										<svg viewBox="0 0 20 16" width="20" height="16">
											<path d="M8.2 10.4a3.4 3.4 0 0 0 4.8 0l3-3a3.4 3.4 0 0 0-4.8-4.8L9.8 4M11.8 5.6a3.4 3.4 0 0 0-4.8 0l-3 3a3.4 3.4 0 0 0 4.8 4.8l1.4-1.4" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" />
										</svg>
									</span>
									<div class="thc-pill">
										<ul class="thc-social" role="list" aria-label={`${card.name} on social media`}>
											{card.socials.map((s) => (
												<li>
													<a class="thc-social-link" href={s.href} rel="noopener noreferrer" target="_blank">
														<span class="thc-sr">{LABELS[s.network]} (opens in a new tab)</span>
														{s.network === "instagram" && (
															<svg viewBox="0 0 14 14" width="14" height="14" aria-hidden="true" focusable="false">
																<rect x="1.2" y="1.2" width="11.6" height="11.6" rx="3.4" fill="none" stroke="currentColor" stroke-width="1.4" />
																<circle cx="7" cy="7" r="2.7" fill="none" stroke="currentColor" stroke-width="1.4" />
																<circle cx="10.4" cy="3.6" r="0.9" fill="currentColor" />
															</svg>
														)}
														{s.network === "x" && (
															<svg viewBox="0 0 14 14" width="14" height="14" aria-hidden="true" focusable="false">
																<path d="M1.5 1.5h3.3l7.7 11H9.2zM12.3 1.5 8 6.4M1.8 12.5 6 7.7" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round" />
															</svg>
														)}
														{s.network === "facebook" && (
															<svg viewBox="0 0 14 14" width="14" height="14" aria-hidden="true" focusable="false">
																<path d="M8 13.5V7.6h2l.3-2.3H8V3.9c0-.7.2-1.1 1.2-1.1h1.2V.7A15 15 0 0 0 8.6.6C6.9.6 5.7 1.6 5.7 3.5v1.8h-2v2.3h2v5.9z" fill="currentColor" />
															</svg>
														)}
														{s.network === "linkedin" && (
															<svg viewBox="0 0 14 14" width="14" height="14" aria-hidden="true" focusable="false">
																<path d="M1.3 4.8h2.6v8H1.3zM2.6.9a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3M5.6 4.8h2.5v1.1c.4-.7 1.3-1.3 2.6-1.3 2.7 0 3.1 1.7 3.1 4v4.2h-2.6V9.1c0-.9 0-2-1.3-2s-1.6 1-1.6 2v3.7H5.6z" fill="currentColor" />
															</svg>
														)}
													</a>
												</li>
											))}
										</ul>
										<span class="thc-pill-icon" aria-hidden="true">
											<svg viewBox="0 0 20 16" width="20" height="16">
												<path d="M8.2 10.4a3.4 3.4 0 0 0 4.8 0l3-3a3.4 3.4 0 0 0-4.8-4.8L9.8 4M11.8 5.6a3.4 3.4 0 0 0-4.8 0l-3 3a3.4 3.4 0 0 0 4.8 4.8l1.4-1.4" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" />
											</svg>
										</span>
									</div>
								</div>
							)}
						</div>
					</li>
				))
			}
		</ul>
	</div>
</section>
)}

<style>
	.thc {
		--thc-navy: var(--tg-navy, #020e28);
		--thc-band: #001f3f;
		--thc-orange: var(--tg-orange, #fd5523);
		--thc-muted: var(--tg-muted, #61697d);
		--thc-display: var(--tg-font-display, Outfit, "Outfit Placeholder", ui-sans-serif, system-ui, sans-serif);
		--thc-body: var(--tg-font-body, "DM Sans", "DM Sans Placeholder", ui-sans-serif, system-ui, sans-serif);
		--thc-ease: cubic-bezier(0.22, 1, 0.36, 1);

		box-sizing: border-box;
		padding: 80px 0;
		background: var(--thc-bg, var(--tg-surface, #ecf0f1));
		color: var(--thc-navy);
		font-family: var(--thc-body);
		overflow-x: clip;
	}

	.thc *,
	.thc *::before,
	.thc *::after {
		box-sizing: border-box;
	}

	.thc-inner {
		display: flex;
		flex-direction: column;
		gap: 40px;
		max-width: 1264px;
		margin: 0 auto;
		padding: 0 20px;
	}

	.thc-title {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 16px;
	}

	.thc .thc-eyebrow {
		display: inline-flex;
		align-items: center;
		gap: 12px;
		margin: 0;
		font: 600 16px/1 var(--thc-display);
		text-transform: uppercase;
		color: var(--thc-orange);
	}

	/* Left aligned on a phone, as measured, so the lines drop there. */
	.thc-eyebrow-line {
		display: none;
		width: 40px;
		height: 1px;
		background: currentColor;
	}

	.thc .thc-heading {
		margin: 0;
		font: 600 32px/1.2 var(--thc-display);
		color: var(--thc-navy);
		overflow-wrap: break-word;
	}

	.thc .thc-grid {
		display: grid;
		grid-template-columns: minmax(0, 1fr);
		gap: 24px;
		margin: 0;
		padding: 0;
		list-style: none;
	}

	/* ---- card: phone / no-hover layout first ---- */
	.thc-card {
		position: relative;
		display: flex;
		flex-direction: column;
		min-width: 0;
		border-radius: 12px;
		background: #fff9f9;
		overflow: hidden;
	}

	.thc-media {
		position: relative;
		aspect-ratio: 7 / 5;
		overflow: hidden;
	}

	.thc-media-link,
	.thc-media img {
		display: block;
		width: 100%;
		height: 100%;
	}

	.thc-media img {
		object-fit: cover;
		object-position: 50% 25%;
		transition: transform 0.4s var(--thc-ease);
	}

	.thc-strip {
		display: flex;
		flex-direction: column;
		gap: 16px;
		padding: 16px;
	}

	.thc-info {
		display: flex;
		flex-direction: column;
		gap: 6px;
		min-width: 0;
	}

	.thc .thc-name {
		margin: 0;
		font: 600 20px/1.36 var(--thc-display);
		color: var(--thc-navy);
	}

	.thc-name a {
		color: inherit;
		text-decoration: none;
		transition: color 0.3s var(--thc-ease);
	}

	.thc-name a:hover {
		color: var(--thc-orange);
	}

	.thc-name a:focus-visible {
		outline: 2px solid var(--thc-orange);
		outline-offset: 3px;
		border-radius: 2px;
	}

	.thc .thc-role {
		margin: 0;
		font: 400 16px/21.04px var(--thc-body);
		color: var(--thc-orange);
	}

	.thc-share-icon,
	.thc-pill-icon {
		display: none;
	}

	.thc .thc-social {
		display: flex;
		flex-wrap: wrap;
		gap: 8px;
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.thc-social-link {
		display: grid;
		place-items: center;
		width: 44px;
		height: 44px;
		border-radius: 50%;
		background: #fff;
		color: var(--thc-orange);
		transition:
			background-color 0.3s var(--thc-ease),
			color 0.3s var(--thc-ease);
	}

	.thc-social-link svg {
		display: block;
		width: 14px;
		height: 14px;
	}

	.thc-social-link:hover {
		background: var(--thc-orange);
		color: #fff;
	}

	.thc-social-link:focus-visible {
		outline: 2px solid var(--thc-navy);
		outline-offset: 2px;
	}

	.thc-sr {
		position: absolute;
		width: 1px;
		height: 1px;
		margin: -1px;
		padding: 0;
		overflow: hidden;
		clip: rect(0 0 0 0);
		white-space: nowrap;
		border: 0;
	}

	.thc-ph {
		display: grid;
		place-items: center;
		width: 100%;
		height: 100%;
		padding: 16px;
		text-align: center;
		background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
		color: rgb(2 14 40 / 0.62);
	}

	.thc-ph-text {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 8px;
	}

	.thc-ph-label {
		font: 600 12px/1 var(--thc-body);
		letter-spacing: 0.08em;
		text-transform: uppercase;
	}

	.thc-ph-hint {
		max-width: 28ch;
		font: 500 14px/1.4 var(--thc-body);
	}

	.thc-ph-spec {
		font: 400 12px/1.2 var(--thc-body);
	}

	.thc-card:hover .thc-media img {
		transform: scale(1.05);
	}

	/* ---- appear on scroll ---- */
	.thc-title,
	.thc-card {
		transition:
			opacity 0.8s var(--thc-ease),
			transform 0.8s var(--thc-ease);
	}

	.thc-card {
		transition-delay: calc(0.1s + var(--thc-i, 0) * 0.1s);
	}

	.thc[data-thc-ready]:not([data-in]) .thc-title,
	.thc[data-thc-ready]:not([data-in]) .thc-card {
		opacity: 0;
		transform: translateY(40px);
	}

	/* ---- tablet and up: centred title, three-up grid ---- */
	@media (min-width: 810px) {
		.thc {
			padding: 100px 0;
		}

		.thc-inner {
			gap: 60px;
		}

		.thc-title {
			align-items: center;
			text-align: center;
		}

		.thc-eyebrow-line {
			display: block;
		}

		.thc .thc-heading {
			font-size: 40px;
		}

		.thc .thc-grid {
			display: flex;
			flex-wrap: wrap;
			justify-content: center;
		}

		.thc .thc-grid > .thc-card {
			flex: 1 1 220px;
			max-width: 392px;
		}

		.thc-card {
			padding: 20px;
			border-radius: 20px;
			overflow: visible;
			cursor: pointer;
		}

		.thc-media {
			aspect-ratio: 352 / 414;
			border-radius: 15px;
		}
	}

	/* ---- the measured overlay strip and hover reveal: pointer devices ---- */
	@media (min-width: 810px) and (hover: hover) {
		.thc-strip {
			position: absolute;
			right: 20px;
			bottom: 20px;
			left: 20px;
			z-index: 2;
			flex-direction: row;
			justify-content: space-between;
			align-items: center;
			gap: 10px;
			min-height: 85px;
			padding: 13px 20px;
			border-radius: 15px;
			background: #fff;
		}

		.thc-info {
			gap: 8px;
		}

		.thc .thc-name {
			font-size: 22px;
			line-height: 29.92px;
		}

		.thc-share {
			position: relative;
			flex: none;
			width: 56px;
			height: 56px;
		}

		.thc-share-icon,
		.thc-pill-icon {
			display: grid;
			place-items: center;
			width: 56px;
			height: 56px;
			border-radius: 100px;
			color: #fff;
		}

		.thc-share-icon {
			background: var(--thc-band);
			transition: opacity 0.4s var(--thc-ease);
		}

		.thc-pill-icon {
			flex: none;
			margin: 0 -6px;
			background: var(--thc-orange);
		}

		/* The pill rises from the icon: socials on top, orange icon at the
		   bottom, sitting exactly over the navy one. */
		.thc-pill {
			position: absolute;
			bottom: 0;
			left: 0;
			display: flex;
			flex-direction: column;
			justify-content: flex-end;
			align-items: center;
			gap: 10px;
			width: 56px;
			padding: 6px 6px 0;
			border-radius: 100px;
			background: #fff;
			opacity: 0;
			pointer-events: none;
			transition: opacity 0.4s var(--thc-ease);
		}

		.thc .thc-social {
			flex-direction: column;
			flex-wrap: nowrap;
			gap: 8px;
		}

		.thc-social-link {
			background: rgb(253 85 35 / 0.1);
		}

		.thc-card:hover .thc-share-icon,
		.thc-card:focus-within .thc-share-icon {
			opacity: 0;
		}

		.thc-card:hover .thc-pill,
		.thc-card:focus-within .thc-pill {
			opacity: 1;
			pointer-events: auto;
		}
	}

	@media (min-width: 1200px) {
		.thc {
			padding: 120px 0;
		}

		.thc .thc-eyebrow {
			font-size: 18px;
		}

		.thc .thc-heading {
			font-size: 48px;
			line-height: 57.6px;
		}

	}

	@media (prefers-reduced-motion: reduce) {
		.thc-title,
		.thc-card,
		.thc-media img {
			transition: none;
		}

		.thc-card:hover .thc-media img {
			transform: none;
		}

		.thc[data-thc-ready]:not([data-in]) .thc-title,
		.thc[data-thc-ready]:not([data-in]) .thc-card {
			opacity: 1;
			transform: none;
		}
	}

	@media (forced-colors: active) {
		.thc-card {
			border: 1px solid CanvasText;
		}

		.thc-social-link {
			border: 1px solid LinkText;
		}
	}
</style>

<script>
	/*
	 * Appear-on-scroll only; the hover reveal is CSS. One IntersectionObserver
	 * per instance, scoped to its root, torn down on astro:before-swap.
	 */
	function initTeamHoverCards(root: HTMLElement) {
		if (root.dataset.thcReady === "" || root.dataset.in === "") return;

		const controller = new AbortController();
		const { signal } = controller;
		const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

		if (reduced || !("IntersectionObserver" in window)) {
			root.dataset.in = "";
			return;
		}

		root.dataset.thcReady = "";

		const observer = new IntersectionObserver(
			(entries) => {
				if (entries.some((entry) => entry.isIntersecting)) {
					root.dataset.in = "";
					observer.disconnect();
				}
			},
			{ threshold: 0.15 },
		);
		observer.observe(root);
		signal.addEventListener("abort", () => observer.disconnect());

		document.addEventListener(
			"astro:before-swap",
			() => {
				controller.abort();
				delete root.dataset.thcReady;
			},
			{ once: true, signal },
		);
	}

	const boot = () =>
		document.querySelectorAll<HTMLElement>("[data-thc-root]").forEach(initTeamHoverCards);

	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", boot, { once: true });
	} else {
		boot();
	}

	document.addEventListener("astro:page-load", boot);
</script>

===== END src/components/TeamHoverCards.astro =====

===== FILE (supporting): src/components/TeamHoverCards.md =====
# Team hover cards

A light grey section: a centred orange eyebrow between two short lines, a big
navy heading, and three portrait cards. Each card is a pale pink frame around
a rounded photo, with a white name / role strip over the photo's bottom edge
and a navy round share icon on its right. Hover a card and the photo zooms a
little while the icon gives way to a pill of social links.

Catalog page: `/components/team-hover-cards`

Part of the Northpass Logistics family (`--tg-*` tokens). Used unchanged on
the home and about pages.

## Files required

**`TeamHoverCards.astro`**: one file. No imports, no packages, social icons
drawn inline.

`TeamHoverCards.md`: this file.

`public/team-hover-cards/member-1.webp`, `member-2.webp`, `member-3.webp`:
the catalog demo's portraits. The component has no default members, so they
are only needed if you pass them yourself.

## Basic usage

```astro
---
import TeamHoverCards from "../components/TeamHoverCards.astro";
---

<TeamHoverCards
  heading="Meet the Team Behind Your Freight"
  members={[
    {
      name: "Karim Haddad",
      role: "Chief Executive",
      href: "/team/karim",
      image: { src: "/img/karim.webp", alt: "Karim Haddad" },
      socials: [
        { network: "linkedin", href: "https://linkedin.com/in/..." },
        { network: "x", href: "https://x.com/..." },
      ],
    },
  ]}
/>
```

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `eyebrow` | `string?` | `"Our People"` | `""` drops it. |
| `heading` | `string?` | `""` | Omitted when empty. |
| `headingLevel` | `"h2" \| "h3"?` | `"h2"` | |
| `memberHeadingLevel` | `"h3" \| "h4"?` | one below | Level of each name. |
| `members` | `TeamMember[]?` | `[]` | `{ name, role?, href?, image?, socials? }`. No members, no section: it renders nothing. |
| `background` | `string?` | `#ecf0f1` | Section surface. |
| `fontStylesheet` | `string?` | Outfit + DM Sans | `""` to skip. |
| `class`, `id` | `string?` | | On the root. |

`socials` is `{ network: "instagram" | "x" | "facebook" | "linkedin", href }[]`.
Only the links you pass are drawn: a member without `socials` has no share
icon and no links, rather than links to the networks' home pages. There are
no built-in people: the heading, the members, their portraits and links all
come from the caller (the catalog preview passes its own demo team).
`image: { src: "" }` renders a labelled placeholder.

## Hover and focus (measured)

From 810px, on devices that can hover (`(hover: hover)`):

| Element | Rest | Hover / focus-within |
| :--- | :--- | :--- |
| Photo | `scale(1)` | `scale(1.05)`, 0.4s |
| Navy share icon (56px, `#001f3f`) | opacity 1 | opacity 0, 0.4s |
| Social pill (56px wide, white, rises from the icon) | opacity 0 | opacity 1, 0.4s |
| Social link (44px, `rgba(253,85,35,.1)`, orange glyph) | | orange, white glyph |

The pill holds the links stacked above an orange copy of the share icon that
sits exactly over the navy one. It reveals on `:focus-within` as well, so
tabbing into a card shows its links; it ignores the pointer while hidden.

On a phone (< 810px), and on touch screens at any width, there is no hover:
the name strip sits under the photo and the social links are a plain row of
white 44px circles, as on the reference phone layout.

## Layout

- **Desktop:** three 392px cards, 24px gap; 20px pink padding; photo 352 × 414
  at 15px radius; strip 85px tall, 13px 20px padding, 15px radius; name
  Outfit 600 22px, role DM Sans 16px orange.
- **Tablet:** cards wrap in a centred row (`flex: 1 1 220px`, max 392px).
- **Phone:** one column, photo 7:5 on top, title block left aligned (eyebrow
  lines hidden), as measured.

## Motion

Appear on scroll, once: title block and cards rise from `translateY(40px)`
and fade in over 0.8s, cards staggered 0.1s. Hidden only after the script
sets `data-thc-ready`. Reduced motion: no appear, no zoom.

## Accessibility

- Cards are a list; each name is a heading, linked to `href` when given.
- The photo repeats that link but is `aria-hidden` with `tabindex="-1"`, so
  there is one profile tab stop per card. Without `href` the photo keeps its
  alt text.
- Social links have visually hidden names ("Instagram (opens in a new tab)")
  inside a list labelled "*Name* on social media".

## Script

Appear only. One `IntersectionObserver` per instance, aborted on
`astro:before-swap`, idempotent, initialised on load and `astro:page-load`.

## Images and credits

| File | Size | Source |
| :--- | :--- | :--- |
| `member-1.webp` | 704 × 828 | Unsplash `K9T9hdf4PmI` |
| `member-2.webp` | 704 × 828 | Unsplash `2VI4kyU7cQ0` |
| `member-3.webp` | 704 × 828 | Unsplash `GGGbGOiJeyw` |

All Unsplash License (free commercial use, no attribution required). The
demo names are invented and live in the catalog's demo props, not in the
component.

## Approximations

- **Social links are 44px, not the measured 36px**, for touch-target size;
  the pill grows to 220px tall (measured 219px), so the silhouette matches.
- The social link hover (orange fill, white glyph) is inferred from the
  "Hover Icon" layer in the dump; it was not measured directly.
- The pill's reveal is a plain fade as measured (opacity 0 → 1); no movement
  was captured.
- Tablet layout is inferred; there was no tablet capture.

===== END src/components/TeamHoverCards.md =====

===== FILE (required): public/team-hover-cards/member-1.webp =====
[binary asset — download https://astro.baysixmedia.com/team-hover-cards/member-1.webp and save it to public/team-hover-cards/member-1.webp]

===== FILE (required): public/team-hover-cards/member-2.webp =====
[binary asset — download https://astro.baysixmedia.com/team-hover-cards/member-2.webp and save it to public/team-hover-cards/member-2.webp]

===== FILE (required): public/team-hover-cards/member-3.webp =====
[binary asset — download https://astro.baysixmedia.com/team-hover-cards/member-3.webp and save it to public/team-hover-cards/member-3.webp]

--- VERIFIED IN THE COMPONENT LIBRARY ---
Enrolled for verification but never run.
No verification run has been recorded.
No checks have passed yet.
No usable source fingerprint, so these results cannot be tied to the files below.

--- NOT PROVEN ---
- ClientRouter: event-tested only. Handlers respond to dispatched lifecycle events; this has NOT been certified against a real <ClientRouter /> transition.

--- WHAT THIS DOES NOT CERTIFY ---
Verification was performed on the unmodified component, in an isolated fixture, in the
library. It says nothing about this project's CSS, routes, layout or data, and nothing
about the component once you adapt it. A verified component is a good starting point,
not a guarantee about the installation you are about to build.

--- TEST IN THIS PROJECT AFTER INSTALLING OR CUSTOMISING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.

Component reference

A fragment for a request you write yourself, e.g. “I want to add [paste] to Section 3 of my homepage.” It names the component, links its page, and fixes what must not change — the design build and its effects — while leaving branding and content open. It does not carry the code.

Read it first
“Team hover cards” from my Astro Component Library:
https://astro.baysixmedia.com/components/team-hover-cards/

Use the existing library component as the implementation source. Preserve its design structure, layout, proportions, spacing system, responsive behavior, visual effects, animations, transitions, hover states, interactions, and accessibility.

The copy, colors, typography, images, and other content may be adapted to match the destination website. Do not redesign, simplify, or reinterpret the component’s underlying design build or effects.

Component readiness

Judged on recorded evidence, not on how it looks in the frame above. A description of a component is not a result.

Interactivity
Ships script
Profile
core only
Installation files
4 required + 1 supporting
Verified
Never
Client navigation
Event-tested only — not certified against a real router
Source fingerprint
No fingerprint recorded
Production ready
Not yet

Not yet verified

Enrolled for verification, but never run. Silence is reported as silence rather than assumed to be fine.

Open the verification fixtureOpen the ClientRouter fixture

src/components/library/TeamHoverCards.astro

This component is not one file on its own

  • assetsCopy these across too — they are referenced by URL, so nothing in the source above points at them: public/team-hover-cards/member-1.webp, public/team-hover-cards/member-2.webp, public/team-hover-cards/member-3.webp. Leave them behind and those URLs resolve to nothing on the other end. What each component does without them is in its README.
---
/**
 * TeamHoverCards
 *
 * A light surface section: a centred orange eyebrow between two short lines,
 * a big navy heading, and three portrait cards. Each card is a pale pink
 * frame around a rounded photo, with a white name / role strip over the
 * photo's bottom edge and a navy round "share" icon on its right.
 *
 * Hover (pointer devices, 810px and up), as measured: the photo scales to
 * 1.05; the navy share icon fades out and a white pill of social links fades
 * in, rising from the icon, capped by an orange copy of the icon. The same
 * reveal runs on :focus-within, so the social links are reachable by
 * keyboard. On a phone, and on touch screens that cannot hover, the links are
 * a plain row under the name instead, as on the reference phone layout.
 *
 * Appear on scroll: the title block and the cards rise 40px, staggered 0.1s.
 * Reads --tg-* design tokens from any ancestor, with fallbacks.
 */

export type TeamSocialNetwork = "instagram" | "x" | "facebook" | "linkedin";

export interface TeamSocialLink {
	network: TeamSocialNetwork;
	href: string;
}

export interface TeamMember {
	name: string;
	role?: string;
	/** Profile page. The name (and the photo) link to it when set. */
	href?: string;
	/** `src: ""` renders the labelled placeholder. */
	image?: { src?: string; alt?: string };
	/** `[]` drops the share icon and the social links. */
	socials?: TeamSocialLink[];
}

export interface TeamHoverCardsProps {
	eyebrow?: string;
	heading?: string;
	headingLevel?: "h2" | "h3";
	/** Level of each name. Defaults to one below the heading. */
	memberHeadingLevel?: "h3" | "h4";
	members?: TeamMember[];
	/** The section surface. */
	background?: string;
	fontStylesheet?: string;
	class?: string;
	id?: string;
}

interface Props extends TeamHoverCardsProps {}

/*
 * No built-in people: the section draws only the members you pass, and a
 * member without `socials` gets none. The component never invents a
 * person or a profile; the catalog demo passes its own.
 */
const {
	eyebrow = "Our People",
	heading = "",
	headingLevel = "h2",
	memberHeadingLevel,
	members = [],
	background,
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=DM+Sans:wght@400;500;600&display=swap",
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;
const NameHeading = memberHeadingLevel ?? (headingLevel === "h2" ? "h3" : "h4");

const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE.test(href.trim()) ? href.trim() : "#");

const LABELS: Record<TeamSocialNetwork, string> = {
	instagram: "Instagram",
	x: "X",
	facebook: "Facebook",
	linkedin: "LinkedIn",
};

const cards = members.map((m) => {
	const image = { src: "", alt: "", ...(m.image ?? {}) };
	return {
		...m,
		image,
		href: m.href ? safeHref(m.href) : undefined,
		socials: (m.socials ?? []).map((s) => ({ ...s, href: safeHref(s.href) })),
	};
});

/* Measured slot: 352 × 414 at desktop (~0.85:1); the files are 704 × 828. */
const IMAGE_SPEC = "~0.85:1 · 704 × 828 px";
---

{cards.length > 0 && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{cards.length > 0 && (
<section
	class:list={["thc", className]}
	id={id}
	style={background ? `--thc-bg: ${background};` : undefined}
	data-thc-root
>
	<div class="thc-inner">
		{(eyebrow || heading) && (
		<div class="thc-title" data-thc-rise>
			{
				eyebrow && (
					<p class="thc-eyebrow">
						<span class="thc-eyebrow-line" aria-hidden="true" />
						{eyebrow}
						<span class="thc-eyebrow-line" aria-hidden="true" />
					</p>
				)
			}
			{heading && <Heading class="thc-heading">{heading}</Heading>}
		</div>
		)}

		<ul class="thc-grid" role="list">
			{
				cards.map((card, i) => (
					<li class="thc-card" data-thc-rise style={`--thc-i: ${i};`}>
						<div class="thc-media">
							{card.image.src ? (
								card.href ? (
									<a class="thc-media-link" href={card.href} tabindex="-1" aria-hidden="true">
										<img src={card.image.src} alt="" width="704" height="828" loading="lazy" decoding="async" />
									</a>
								) : (
									<img src={card.image.src} alt={card.image.alt} width="704" height="828" loading="lazy" decoding="async" />
								)
							) : (
								<div class="thc-ph" role="img" aria-label={`Image placeholder: portrait of ${card.name}`}>
									<div class="thc-ph-text" aria-hidden="true">
										<span class="thc-ph-label">Image</span>
										<span class="thc-ph-hint">Portrait, head and shoulders</span>
										<span class="thc-ph-spec">{IMAGE_SPEC}</span>
									</div>
								</div>
							)}
						</div>
						<div class="thc-strip">
							<div class="thc-info">
								<NameHeading class="thc-name">
									{card.href ? <a href={card.href}>{card.name}</a> : card.name}
								</NameHeading>
								{card.role && <p class="thc-role">{card.role}</p>}
							</div>
							{card.socials.length > 0 && (
								<div class="thc-share">
									<span class="thc-share-icon" aria-hidden="true">
										<svg viewBox="0 0 20 16" width="20" height="16">
											<path d="M8.2 10.4a3.4 3.4 0 0 0 4.8 0l3-3a3.4 3.4 0 0 0-4.8-4.8L9.8 4M11.8 5.6a3.4 3.4 0 0 0-4.8 0l-3 3a3.4 3.4 0 0 0 4.8 4.8l1.4-1.4" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" />
										</svg>
									</span>
									<div class="thc-pill">
										<ul class="thc-social" role="list" aria-label={`${card.name} on social media`}>
											{card.socials.map((s) => (
												<li>
													<a class="thc-social-link" href={s.href} rel="noopener noreferrer" target="_blank">
														<span class="thc-sr">{LABELS[s.network]} (opens in a new tab)</span>
														{s.network === "instagram" && (
															<svg viewBox="0 0 14 14" width="14" height="14" aria-hidden="true" focusable="false">
																<rect x="1.2" y="1.2" width="11.6" height="11.6" rx="3.4" fill="none" stroke="currentColor" stroke-width="1.4" />
																<circle cx="7" cy="7" r="2.7" fill="none" stroke="currentColor" stroke-width="1.4" />
																<circle cx="10.4" cy="3.6" r="0.9" fill="currentColor" />
															</svg>
														)}
														{s.network === "x" && (
															<svg viewBox="0 0 14 14" width="14" height="14" aria-hidden="true" focusable="false">
																<path d="M1.5 1.5h3.3l7.7 11H9.2zM12.3 1.5 8 6.4M1.8 12.5 6 7.7" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round" />
															</svg>
														)}
														{s.network === "facebook" && (
															<svg viewBox="0 0 14 14" width="14" height="14" aria-hidden="true" focusable="false">
																<path d="M8 13.5V7.6h2l.3-2.3H8V3.9c0-.7.2-1.1 1.2-1.1h1.2V.7A15 15 0 0 0 8.6.6C6.9.6 5.7 1.6 5.7 3.5v1.8h-2v2.3h2v5.9z" fill="currentColor" />
															</svg>
														)}
														{s.network === "linkedin" && (
															<svg viewBox="0 0 14 14" width="14" height="14" aria-hidden="true" focusable="false">
																<path d="M1.3 4.8h2.6v8H1.3zM2.6.9a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3M5.6 4.8h2.5v1.1c.4-.7 1.3-1.3 2.6-1.3 2.7 0 3.1 1.7 3.1 4v4.2h-2.6V9.1c0-.9 0-2-1.3-2s-1.6 1-1.6 2v3.7H5.6z" fill="currentColor" />
															</svg>
														)}
													</a>
												</li>
											))}
										</ul>
										<span class="thc-pill-icon" aria-hidden="true">
											<svg viewBox="0 0 20 16" width="20" height="16">
												<path d="M8.2 10.4a3.4 3.4 0 0 0 4.8 0l3-3a3.4 3.4 0 0 0-4.8-4.8L9.8 4M11.8 5.6a3.4 3.4 0 0 0-4.8 0l-3 3a3.4 3.4 0 0 0 4.8 4.8l1.4-1.4" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" />
											</svg>
										</span>
									</div>
								</div>
							)}
						</div>
					</li>
				))
			}
		</ul>
	</div>
</section>
)}

<style>
	.thc {
		--thc-navy: var(--tg-navy, #020e28);
		--thc-band: #001f3f;
		--thc-orange: var(--tg-orange, #fd5523);
		--thc-muted: var(--tg-muted, #61697d);
		--thc-display: var(--tg-font-display, Outfit, "Outfit Placeholder", ui-sans-serif, system-ui, sans-serif);
		--thc-body: var(--tg-font-body, "DM Sans", "DM Sans Placeholder", ui-sans-serif, system-ui, sans-serif);
		--thc-ease: cubic-bezier(0.22, 1, 0.36, 1);

		box-sizing: border-box;
		padding: 80px 0;
		background: var(--thc-bg, var(--tg-surface, #ecf0f1));
		color: var(--thc-navy);
		font-family: var(--thc-body);
		overflow-x: clip;
	}

	.thc *,
	.thc *::before,
	.thc *::after {
		box-sizing: border-box;
	}

	.thc-inner {
		display: flex;
		flex-direction: column;
		gap: 40px;
		max-width: 1264px;
		margin: 0 auto;
		padding: 0 20px;
	}

	.thc-title {
		display: flex;
		flex-direction: column;
		align-items: flex-start;
		gap: 16px;
	}

	.thc .thc-eyebrow {
		display: inline-flex;
		align-items: center;
		gap: 12px;
		margin: 0;
		font: 600 16px/1 var(--thc-display);
		text-transform: uppercase;
		color: var(--thc-orange);
	}

	/* Left aligned on a phone, as measured, so the lines drop there. */
	.thc-eyebrow-line {
		display: none;
		width: 40px;
		height: 1px;
		background: currentColor;
	}

	.thc .thc-heading {
		margin: 0;
		font: 600 32px/1.2 var(--thc-display);
		color: var(--thc-navy);
		overflow-wrap: break-word;
	}

	.thc .thc-grid {
		display: grid;
		grid-template-columns: minmax(0, 1fr);
		gap: 24px;
		margin: 0;
		padding: 0;
		list-style: none;
	}

	/* ---- card: phone / no-hover layout first ---- */
	.thc-card {
		position: relative;
		display: flex;
		flex-direction: column;
		min-width: 0;
		border-radius: 12px;
		background: #fff9f9;
		overflow: hidden;
	}

	.thc-media {
		position: relative;
		aspect-ratio: 7 / 5;
		overflow: hidden;
	}

	.thc-media-link,
	.thc-media img {
		display: block;
		width: 100%;
		height: 100%;
	}

	.thc-media img {
		object-fit: cover;
		object-position: 50% 25%;
		transition: transform 0.4s var(--thc-ease);
	}

	.thc-strip {
		display: flex;
		flex-direction: column;
		gap: 16px;
		padding: 16px;
	}

	.thc-info {
		display: flex;
		flex-direction: column;
		gap: 6px;
		min-width: 0;
	}

	.thc .thc-name {
		margin: 0;
		font: 600 20px/1.36 var(--thc-display);
		color: var(--thc-navy);
	}

	.thc-name a {
		color: inherit;
		text-decoration: none;
		transition: color 0.3s var(--thc-ease);
	}

	.thc-name a:hover {
		color: var(--thc-orange);
	}

	.thc-name a:focus-visible {
		outline: 2px solid var(--thc-orange);
		outline-offset: 3px;
		border-radius: 2px;
	}

	.thc .thc-role {
		margin: 0;
		font: 400 16px/21.04px var(--thc-body);
		color: var(--thc-orange);
	}

	.thc-share-icon,
	.thc-pill-icon {
		display: none;
	}

	.thc .thc-social {
		display: flex;
		flex-wrap: wrap;
		gap: 8px;
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.thc-social-link {
		display: grid;
		place-items: center;
		width: 44px;
		height: 44px;
		border-radius: 50%;
		background: #fff;
		color: var(--thc-orange);
		transition:
			background-color 0.3s var(--thc-ease),
			color 0.3s var(--thc-ease);
	}

	.thc-social-link svg {
		display: block;
		width: 14px;
		height: 14px;
	}

	.thc-social-link:hover {
		background: var(--thc-orange);
		color: #fff;
	}

	.thc-social-link:focus-visible {
		outline: 2px solid var(--thc-navy);
		outline-offset: 2px;
	}

	.thc-sr {
		position: absolute;
		width: 1px;
		height: 1px;
		margin: -1px;
		padding: 0;
		overflow: hidden;
		clip: rect(0 0 0 0);
		white-space: nowrap;
		border: 0;
	}

	.thc-ph {
		display: grid;
		place-items: center;
		width: 100%;
		height: 100%;
		padding: 16px;
		text-align: center;
		background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
		color: rgb(2 14 40 / 0.62);
	}

	.thc-ph-text {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 8px;
	}

	.thc-ph-label {
		font: 600 12px/1 var(--thc-body);
		letter-spacing: 0.08em;
		text-transform: uppercase;
	}

	.thc-ph-hint {
		max-width: 28ch;
		font: 500 14px/1.4 var(--thc-body);
	}

	.thc-ph-spec {
		font: 400 12px/1.2 var(--thc-body);
	}

	.thc-card:hover .thc-media img {
		transform: scale(1.05);
	}

	/* ---- appear on scroll ---- */
	.thc-title,
	.thc-card {
		transition:
			opacity 0.8s var(--thc-ease),
			transform 0.8s var(--thc-ease);
	}

	.thc-card {
		transition-delay: calc(0.1s + var(--thc-i, 0) * 0.1s);
	}

	.thc[data-thc-ready]:not([data-in]) .thc-title,
	.thc[data-thc-ready]:not([data-in]) .thc-card {
		opacity: 0;
		transform: translateY(40px);
	}

	/* ---- tablet and up: centred title, three-up grid ---- */
	@media (min-width: 810px) {
		.thc {
			padding: 100px 0;
		}

		.thc-inner {
			gap: 60px;
		}

		.thc-title {
			align-items: center;
			text-align: center;
		}

		.thc-eyebrow-line {
			display: block;
		}

		.thc .thc-heading {
			font-size: 40px;
		}

		.thc .thc-grid {
			display: flex;
			flex-wrap: wrap;
			justify-content: center;
		}

		.thc .thc-grid > .thc-card {
			flex: 1 1 220px;
			max-width: 392px;
		}

		.thc-card {
			padding: 20px;
			border-radius: 20px;
			overflow: visible;
			cursor: pointer;
		}

		.thc-media {
			aspect-ratio: 352 / 414;
			border-radius: 15px;
		}
	}

	/* ---- the measured overlay strip and hover reveal: pointer devices ---- */
	@media (min-width: 810px) and (hover: hover) {
		.thc-strip {
			position: absolute;
			right: 20px;
			bottom: 20px;
			left: 20px;
			z-index: 2;
			flex-direction: row;
			justify-content: space-between;
			align-items: center;
			gap: 10px;
			min-height: 85px;
			padding: 13px 20px;
			border-radius: 15px;
			background: #fff;
		}

		.thc-info {
			gap: 8px;
		}

		.thc .thc-name {
			font-size: 22px;
			line-height: 29.92px;
		}

		.thc-share {
			position: relative;
			flex: none;
			width: 56px;
			height: 56px;
		}

		.thc-share-icon,
		.thc-pill-icon {
			display: grid;
			place-items: center;
			width: 56px;
			height: 56px;
			border-radius: 100px;
			color: #fff;
		}

		.thc-share-icon {
			background: var(--thc-band);
			transition: opacity 0.4s var(--thc-ease);
		}

		.thc-pill-icon {
			flex: none;
			margin: 0 -6px;
			background: var(--thc-orange);
		}

		/* The pill rises from the icon: socials on top, orange icon at the
		   bottom, sitting exactly over the navy one. */
		.thc-pill {
			position: absolute;
			bottom: 0;
			left: 0;
			display: flex;
			flex-direction: column;
			justify-content: flex-end;
			align-items: center;
			gap: 10px;
			width: 56px;
			padding: 6px 6px 0;
			border-radius: 100px;
			background: #fff;
			opacity: 0;
			pointer-events: none;
			transition: opacity 0.4s var(--thc-ease);
		}

		.thc .thc-social {
			flex-direction: column;
			flex-wrap: nowrap;
			gap: 8px;
		}

		.thc-social-link {
			background: rgb(253 85 35 / 0.1);
		}

		.thc-card:hover .thc-share-icon,
		.thc-card:focus-within .thc-share-icon {
			opacity: 0;
		}

		.thc-card:hover .thc-pill,
		.thc-card:focus-within .thc-pill {
			opacity: 1;
			pointer-events: auto;
		}
	}

	@media (min-width: 1200px) {
		.thc {
			padding: 120px 0;
		}

		.thc .thc-eyebrow {
			font-size: 18px;
		}

		.thc .thc-heading {
			font-size: 48px;
			line-height: 57.6px;
		}

	}

	@media (prefers-reduced-motion: reduce) {
		.thc-title,
		.thc-card,
		.thc-media img {
			transition: none;
		}

		.thc-card:hover .thc-media img {
			transform: none;
		}

		.thc[data-thc-ready]:not([data-in]) .thc-title,
		.thc[data-thc-ready]:not([data-in]) .thc-card {
			opacity: 1;
			transform: none;
		}
	}

	@media (forced-colors: active) {
		.thc-card {
			border: 1px solid CanvasText;
		}

		.thc-social-link {
			border: 1px solid LinkText;
		}
	}
</style>

<script>
	/*
	 * Appear-on-scroll only; the hover reveal is CSS. One IntersectionObserver
	 * per instance, scoped to its root, torn down on astro:before-swap.
	 */
	function initTeamHoverCards(root: HTMLElement) {
		if (root.dataset.thcReady === "" || root.dataset.in === "") return;

		const controller = new AbortController();
		const { signal } = controller;
		const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

		if (reduced || !("IntersectionObserver" in window)) {
			root.dataset.in = "";
			return;
		}

		root.dataset.thcReady = "";

		const observer = new IntersectionObserver(
			(entries) => {
				if (entries.some((entry) => entry.isIntersecting)) {
					root.dataset.in = "";
					observer.disconnect();
				}
			},
			{ threshold: 0.15 },
		);
		observer.observe(root);
		signal.addEventListener("abort", () => observer.disconnect());

		document.addEventListener(
			"astro:before-swap",
			() => {
				controller.abort();
				delete root.dataset.thcReady;
			},
			{ once: true, signal },
		);
	}

	const boot = () =>
		document.querySelectorAll<HTMLElement>("[data-thc-root]").forEach(initTeamHoverCards);

	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", boot, { once: true });
	} else {
		boot();
	}

	document.addEventListener("astro:page-load", boot);
</script>