Component library
Back to catalog

Industry tabs panel

A light section with a lime-dot eyebrow, a big heading, a row of pill tabs and one 1240×520 rounded panel — photo on the left, ink side with heading, paragraph, two lime stat tiles and a lime pill. Real WAI-ARIA tabs; stacks below 810px.

tabsindustriesimagestatsdarkappear

Open preview in a new tab

Use this component

Three ways to get Industry tabs panel 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 6 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 Industry tabs panel from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/industry-tabs-panel/

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 6 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: "Industry tabs panel"
Library page: https://astro.baysixmedia.com/components/industry-tabs-panel/
Files: 6 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/IndustryTabsPanel.astro =====
---
/**
 * IndustryTabsPanel
 *
 * A light section: a lime-dot eyebrow on the left and a big heading on the
 * right, a row of pill tabs, and under them one rounded panel split into a
 * photo and an ink content side (eyebrow, heading, paragraph, two stat
 * tiles, a lime "View solution" pill). Each tab swaps the whole panel.
 *
 * WAI-ARIA tabs with AUTOMATIC activation: arrow keys move focus and select
 * in one step, which is fine here because every panel is already in the
 * document and a swap costs nothing.
 *
 * No script: the tab row is hidden and all panels are shown stacked (see
 * the <noscript> block), so nothing is stranded behind a control that cannot
 * work.
 *
 * Reads --nv-* design tokens from any ancestor, with fallbacks. Never sets
 * them.
 */

export interface IndustryTabStat {
	value: string;
	label: string;
}

export interface IndustryTab {
	/** The pill label, and the panel's accessible name. */
	label: string;
	/** Small uppercase line above the panel heading. `""` hides it. */
	eyebrow?: string;
	heading: string;
	text?: string;
	/** Up to two read best; more wrap onto a new row. */
	stats?: IndustryTabStat[];
	/**
	 * The photo. `src: ""` (or no `src`) renders the labelled placeholder
	 * instead, naming `hint` (or `alt`, or the label) and the size to supply.
	 */
	image?: { src?: string; alt?: string; hint?: string };
	/** The lime pill. `null` drops it. */
	cta?: { label: string; href: string } | null;
}

export interface IndustryTabsPanelProps {
	eyebrow?: string;
	heading?: string;
	headingLevel?: "h2" | "h3";
	/** Accessible name of the tab row. */
	tabsLabel?: string;
	tabs?: IndustryTab[];
	/** Which tab starts selected, counting from 1. Clamped to the list. */
	initialTab?: number;
	/** The section surface. */
	background?: string;
	fontStylesheet?: string;
	id?: string;
}

interface Props extends IndustryTabsPanelProps {}

const {
	eyebrow = "",
	heading = "",
	headingLevel = "h2",
	tabsLabel = "Industries",
	tabs = [],
	initialTab = 1,
	background = "var(--nv-surface, #f8f9f6)",
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@400;500;600;700;800&display=swap",
	id,
} = Astro.props;

const Heading = headingLevel;
const PanelHeading = headingLevel === "h2" ? "h3" : "h4";

/*
 * A tab needs a label to click and a heading for its panel; one missing either
 * is dropped rather than drawn half-empty, as is a stat without both a value
 * and a label, or a CTA without a label. No tabs, no section: the heading alone
 * never renders.
 */
const list = tabs
	.filter((tab) => tab?.label && tab?.heading)
	.map((tab) => ({
		...tab,
		stats: tab.stats?.filter((stat) => stat?.value && stat?.label),
		cta: tab.cta?.label ? tab.cta : null,
	}));

const uid = id ?? `itp-${Math.random().toString(36).slice(2, 9)}`;
const active = Math.min(Math.max(Math.round(initialTab) - 1, 0), Math.max(list.length - 1, 0));

/* Only schemes that are links, so `javascript:` never reaches an href. */
const SAFE_SCHEME = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE_SCHEME.test(href.trim()) ? href.trim() : undefined);

/* The supplied photos are 1240 × 1040 and fill a 620 × 520 box at desktop. */
const IMAGE_SPEC = "31:26 · 1240 × 1040 px";
---

{list.length > 0 && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{list.length > 0 && (
<section
	class="itp"
	id={id}
	style={`--itp-bg: ${background};`}
	data-itp-root
	aria-labelledby={heading ? `${uid}-heading` : undefined}
	aria-label={heading ? undefined : tabsLabel}
>
	<div class="itp-inner">
		{
			(eyebrow || heading) && (
				<div class="itp-head">
					{eyebrow ? (
						<p class="itp-eyebrow">
							<span class="itp-dot" aria-hidden="true" />
							{eyebrow}
						</p>
					) : (
						<span class="itp-eyebrow-spacer" aria-hidden="true" />
					)}
					{heading && (
						<Heading class="itp-heading" id={`${uid}-heading`}>
							{heading}
						</Heading>
					)}
				</div>
			)
		}

		<div class="itp-tabs" role="tablist" aria-label={tabsLabel} data-itp-tablist>
			{
				list.map((tab, i) => (
					<button
						type="button"
						class="itp-tab"
						role="tab"
						id={`${uid}-tab-${i}`}
						aria-controls={`${uid}-panel-${i}`}
						aria-selected={i === active ? "true" : "false"}
						tabindex={i === active ? 0 : -1}
						data-itp-tab
					>
						{tab.label}
					</button>
				))
			}
		</div>

		<div class="itp-stage" data-itp-stage>
			{
				list.map((tab, i) => {
					const href = tab.cta ? safeHref(tab.cta.href) : undefined;
					const src = tab.image?.src;
					const hint = tab.image?.hint ?? tab.image?.alt ?? `${tab.label} photo`;
					return (
						<div
							class="itp-panel"
							role="tabpanel"
							id={`${uid}-panel-${i}`}
							aria-labelledby={`${uid}-tab-${i}`}
							hidden={i !== active}
							data-itp-panel
						>
							<div class="itp-media">
								{src ? (
									<img
										src={src}
										alt={tab.image?.alt ?? ""}
										width="620"
										height="520"
										loading="lazy"
										decoding="async"
									/>
								) : (
									<div class="itp-ph" role="img" aria-label={`Image placeholder: ${hint}`}>
										<div class="itp-ph-text" aria-hidden="true">
											<span class="itp-ph-label">Image</span>
											<span class="itp-ph-hint">{hint}</span>
											<span class="itp-ph-spec">{IMAGE_SPEC}</span>
										</div>
									</div>
								)}
							</div>
							<div class="itp-content">
								{tab.eyebrow && (
									<p class="itp-kicker">
										<span class="itp-dot" aria-hidden="true" />
										{tab.eyebrow}
									</p>
								)}
								<PanelHeading class="itp-title">{tab.heading}</PanelHeading>
								{tab.text && <p class="itp-text">{tab.text}</p>}
								{tab.stats && tab.stats.length > 0 && (
									<ul class="itp-stats">
										{tab.stats.map((stat) => (
											<li class="itp-stat">
												<strong class="itp-stat-value">{stat.value}</strong>
												<span class="itp-stat-label">{stat.label}</span>
											</li>
										))}
									</ul>
								)}
								{tab.cta && href && (
									<a class="itp-cta" href={href}>
										<span>{tab.cta.label}</span>
										<svg viewBox="0 0 14 14" width="14" height="14" aria-hidden="true" focusable="false">
											<path
												d="M4 10 10 4M5 4h5v5"
												fill="none"
												stroke="currentColor"
												stroke-width="1.6"
												stroke-linecap="round"
												stroke-linejoin="round"
											/>
										</svg>
									</a>
								)}
							</div>
						</div>
					);
				})
			}
		</div>
	</div>

	<noscript>
		<style>
			/* Without the script the tabs cannot switch anything, so the row is
			   hidden and every panel is shown, stacked. Written against data-*
			   attributes because a style block nested in the markup is not
			   scoped by Astro. */
			[data-itp-root] [data-itp-tablist] {
				display: none !important;
			}
			[data-itp-root] [data-itp-panel][hidden] {
				display: flex !important;
			}
		</style>
	</noscript>
</section>
)}

<style>
	.itp {
		--itp-ink: var(--nv-ink, #08110f);
		--itp-lime: var(--nv-lime, #b7ff39);
		--itp-chip: #e8ece5;
		--itp-chip-hover: #dfe7dd;
		--itp-font: var(--nv-font-ui, Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif);
		--itp-ease: cubic-bezier(0.22, 1, 0.36, 1);

		box-sizing: border-box;
		padding: 82px 18px;
		background: var(--itp-bg);
		color: var(--itp-ink);
		font-family: var(--itp-font);
	}

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

	.itp-inner {
		max-width: 1240px;
		margin: 0 auto;
	}

	/* ---- heading row ---- */
	.itp-head {
		display: flex;
		flex-direction: column;
		gap: 20px;
		/* Measured as two spacers, 50px + 22px. */
		margin-bottom: 72px;
	}

	.itp-eyebrow,
	.itp-kicker {
		display: flex;
		align-items: center;
		margin: 0;
		font-weight: 800;
		text-transform: uppercase;
	}

	.itp-eyebrow {
		gap: 9px;
		font-size: 12px;
		line-height: 14.4px;
		letter-spacing: 1.2px;
	}

	.itp-dot {
		flex: none;
		width: 8px;
		height: 8px;
		border-radius: 100px;
		background: var(--itp-lime);
	}

	.itp-heading {
		margin: 0;
		max-width: 700px;
		font-size: 40px;
		font-weight: 700;
		line-height: 1.02;
		letter-spacing: -0.045em;
		text-wrap: balance;
	}

	.itp-eyebrow-spacer {
		display: none;
	}

	/* ---- tabs ---- */
	.itp-tabs {
		display: flex;
		flex-wrap: wrap;
		gap: 10px;
		margin-bottom: 22px;
	}

	.itp-tab {
		position: relative;
		display: inline-flex;
		align-items: center;
		justify-content: center;
		height: 34px;
		padding: 0 16px;
		border: 0;
		border-radius: 100px;
		background: var(--itp-chip);
		color: var(--itp-ink);
		font: 800 11px/13.2px var(--itp-font);
		white-space: nowrap;
		cursor: pointer;
		transition:
			transform 0.3s var(--itp-ease),
			background-color 0.3s var(--itp-ease),
			box-shadow 0.3s var(--itp-ease),
			color 0.3s var(--itp-ease);
	}

	/* The pill is 34px tall as measured; the hit area is 44px. */
	.itp-tab::before {
		content: "";
		position: absolute;
		inset: -5px 0;
	}

	.itp-tab[aria-selected="true"] {
		background: var(--itp-ink);
		color: #fff;
	}

	.itp-tab:focus-visible {
		outline: 2px solid var(--itp-ink);
		outline-offset: 3px;
	}

	@media (hover: hover) {
		.itp-tab:hover {
			transform: scale(1.02) translateX(4px);
		}

		.itp-tab[aria-selected="false"]:hover {
			background: var(--itp-chip-hover);
		}

		.itp-tab[aria-selected="true"]:hover {
			box-shadow: 0 8px 16px rgb(8 17 15 / 0.12);
		}
	}

	/* ---- panel ---- */
	.itp-stage {
		display: grid;
		gap: 16px;
	}

	.itp-panel {
		display: flex;
		flex-direction: column;
		border-radius: 28px;
		background: var(--itp-ink);
		box-shadow: 0 20px 48px rgb(8 17 15 / 0.12);
		overflow: hidden;
		color: #fff;
	}

	.itp-panel[hidden] {
		display: none;
	}

	.itp-media {
		position: relative;
		flex: none;
		aspect-ratio: 339 / 250;
		overflow: hidden;
	}

	.itp-media img {
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
	}

	.itp-ph {
		display: grid;
		place-items: center;
		width: 100%;
		height: 100%;
		padding: 16px;
		text-align: center;
		background: repeating-linear-gradient(135deg, #1a2522 0 12px, #202c29 12px 24px);
		color: rgb(247 250 247 / 0.7);
	}

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

	.itp-ph-label {
		font: 700 11px/1 var(--itp-font);
		letter-spacing: 0.1em;
		text-transform: uppercase;
	}

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

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

	.itp-content {
		display: flex;
		flex-direction: column;
		justify-content: center;
		align-items: flex-start;
		min-width: 0;
		padding: 36px clamp(22px, 8vw, 32px) 32px;
	}

	.itp-kicker {
		gap: 8px;
		margin-bottom: 20px;
		font-size: 11px;
		line-height: 13.2px;
		letter-spacing: 1.1px;
		color: #fff;
	}

	.itp-title {
		margin: 0;
		max-width: 420px;
		font-size: clamp(34px, 10.5vw, 40px);
		font-weight: 700;
		line-height: 1.05;
		letter-spacing: -0.035em;
		color: #fff;
	}

	.itp-text {
		margin: 26px 0 0;
		max-width: 440px;
		font-size: 15px;
		font-weight: 400;
		line-height: 25.2px;
		color: rgb(255 255 255 / 0.65);
	}

	.itp-stats {
		display: grid;
		grid-template-columns: repeat(2, minmax(0, 1fr));
		gap: 12px;
		width: 100%;
		max-width: 524px;
		margin: 32px 0 0;
		padding: 0;
		list-style: none;
	}

	.itp-stat {
		display: flex;
		flex-direction: column;
		justify-content: center;
		gap: 3px;
		min-height: 76px;
		padding: 16px;
		border-radius: 18px;
		background: rgb(255 255 255 / 0.08);
	}

	.itp-stat-value {
		font-size: 23px;
		font-weight: 800;
		line-height: 23px;
		color: var(--itp-lime);
	}

	.itp-stat-label {
		font-size: 12px;
		font-weight: 500;
		line-height: 14.4px;
		color: #fff;
	}

	.itp-cta {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		gap: 8px;
		height: 44px;
		margin-top: 24px;
		padding: 0 18px;
		border-radius: 100px;
		background: var(--itp-lime);
		color: var(--itp-ink);
		font: 800 12px/14.4px var(--itp-font);
		text-decoration: none;
		white-space: nowrap;
	}

	.itp-cta:focus-visible {
		outline: 2px solid var(--itp-lime);
		outline-offset: 3px;
	}

	/* ---- appear (only once the script has marked the root ready) ---- */
	.itp[data-itp-ready] .itp-stage {
		transition:
			opacity 0.7s var(--itp-ease),
			transform 0.7s var(--itp-ease);
	}

	.itp[data-itp-ready]:not([data-in]) .itp-stage {
		opacity: 0;
		transform: translateY(22px);
	}

	/* A softer re-appear on each tab change. */
	.itp-panel[data-itp-swap] {
		animation: itp-swap 0.45s var(--itp-ease) both;
	}

	@keyframes itp-swap {
		from {
			opacity: 0;
			transform: translateY(10px);
		}
		to {
			opacity: 1;
			transform: none;
		}
	}

	/* ---- tablet ---- */
	@media (min-width: 810px) {
		.itp {
			padding: 100px 40px;
		}

		.itp-heading {
			font-size: 52px;
			letter-spacing: -0.045em;
		}

		.itp-panel {
			flex-direction: row;
			min-height: 480px;
			border-radius: 34px;
		}

		.itp-media {
			flex: 0 0 50%;
			aspect-ratio: auto;
		}

		.itp-content {
			flex: 1 1 50%;
			padding: 44px 40px;
		}

		.itp-title {
			font-size: 38px;
		}
	}

	/* ---- desktop: the measured layout ---- */
	@media (min-width: 1200px) {
		.itp {
			padding: 120px 64px;
		}

		.itp-head {
			flex-direction: row;
			align-items: flex-start;
			gap: 70px;
		}

		.itp-eyebrow,
		.itp-eyebrow-spacer {
			display: flex;
			flex: 0 0 422px;
		}

		.itp-heading {
			flex: 0 1 700px;
			font-size: 62px;
			line-height: 60.76px;
			letter-spacing: -2.79px;
		}

		.itp-panel {
			height: 520px;
			min-height: 0;
		}

		.itp-media {
			flex: 0 0 620px;
		}

		.itp-content {
			padding: 52px 48px;
		}

		.itp-title {
			font-size: 44px;
			line-height: 44px;
			letter-spacing: -1.54px;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.itp-tab {
			transition:
				background-color 0.3s ease,
				box-shadow 0.3s ease,
				color 0.3s ease;
		}

		.itp-tab:hover {
			transform: none !important;
		}

		.itp[data-itp-ready] .itp-stage {
			transition: none;
		}

		.itp[data-itp-ready]:not([data-in]) .itp-stage {
			opacity: 1;
			transform: none;
		}

		.itp-panel[data-itp-swap] {
			animation: none;
		}
	}

	@media (forced-colors: active) {
		.itp-tab {
			border: 1px solid ButtonText;
		}

		.itp-tab[aria-selected="true"] {
			background: Highlight;
			color: HighlightText;
			forced-color-adjust: none;
		}

		.itp-panel,
		.itp-stat {
			border: 1px solid CanvasText;
		}

		.itp-cta {
			border: 1px solid LinkText;
		}

		.itp-dot {
			forced-color-adjust: none;
		}
	}
</style>

<script>
	/*
	 * IndustryTabsPanel controller.
	 *
	 * One closure per instance, every lookup scoped to its root, every
	 * listener and the observer on one AbortController, torn down before a
	 * client-side navigation swaps the document.
	 *
	 * Automatic activation: Left/Right/Home/End move focus AND select. The
	 * roving tabindex keeps exactly one tab in the Tab order.
	 */
	interface Instance {
		destroy(): void;
	}

	function initIndustryTabs(root: HTMLElement): Instance | undefined {
		/* Idempotent: booting twice is a no-op, not a second set of listeners. */
		if (root.dataset.itpReady === "") return undefined;

		const tabs = [...root.querySelectorAll<HTMLButtonElement>("[data-itp-tab]")];
		const panels = [...root.querySelectorAll<HTMLElement>("[data-itp-panel]")];
		const stage = root.querySelector<HTMLElement>("[data-itp-stage]");
		if (tabs.length === 0 || tabs.length !== panels.length || !stage) return undefined;

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

		let index = Math.max(
			0,
			tabs.findIndex((tab) => tab.getAttribute("aria-selected") === "true"),
		);
		const servedIndex = index;

		function select(next: number, focus: boolean) {
			const n = tabs.length;
			next = ((next % n) + n) % n;
			const changed = next !== index;

			tabs.forEach((tab, i) => {
				const on = i === next;
				tab.setAttribute("aria-selected", on ? "true" : "false");
				tab.tabIndex = on ? 0 : -1;
			});
			panels.forEach((panel, i) => {
				panel.hidden = i !== next;
				if (i !== next) panel.removeAttribute("data-itp-swap");
			});

			if (changed && !reduced.matches) {
				const panel = panels[next];
				panel.removeAttribute("data-itp-swap");
				void panel.offsetWidth;
				panel.setAttribute("data-itp-swap", "");
			}

			index = next;
			/* Focus moves only in response to a key press on the tab row. */
			if (focus) tabs[next].focus();
		}

		tabs.forEach((tab, i) => {
			tab.addEventListener("click", () => select(i, false), { signal });
			tab.addEventListener(
				"keydown",
				(event) => {
					let next: number;
					switch (event.key) {
						case "ArrowRight":
							next = i + 1;
							break;
						case "ArrowLeft":
							next = i - 1;
							break;
						case "Home":
							next = 0;
							break;
						case "End":
							next = tabs.length - 1;
							break;
						default:
							return;
					}
					event.preventDefault();
					select(next, true);
				},
				{ signal },
			);
		});

		panels.forEach((panel) =>
			panel.addEventListener("animationend", () => panel.removeAttribute("data-itp-swap"), { signal }),
		);

		/* Warm the other tabs' photos once the section is on screen, so a
		   switch does not wait on a lazy image. */
		function warmImages() {
			root.querySelectorAll<HTMLImageElement>("[data-itp-panel] img").forEach((img) => {
				img.loading = "eager";
			});
		}

		/* Appear: the hidden start state applies only under data-itp-ready,
		   so no-JS and a failed boot both show the panel at rest. */
		let observer: IntersectionObserver | undefined;
		if (reduced.matches || !("IntersectionObserver" in window)) {
			root.setAttribute("data-in", "");
			root.dataset.itpReady = "";
			warmImages();
		} else {
			root.dataset.itpReady = "";
			observer = new IntersectionObserver(
				(entries) => {
					if (entries.some((entry) => entry.isIntersecting)) {
						root.setAttribute("data-in", "");
						warmImages();
						observer?.disconnect();
					}
				},
				{ threshold: 0.15 },
			);
			observer.observe(stage);
		}

		function destroy() {
			observer?.disconnect();
			controller.abort();
			select(servedIndex, false);
			panels.forEach((panel) => panel.removeAttribute("data-itp-swap"));
			root.removeAttribute("data-in");
			delete root.dataset.itpReady;
		}

		document.addEventListener("astro:before-swap", destroy, { once: true, signal });

		return { destroy };
	}

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

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

	/* Fires on every view transition in a project using <ClientRouter />. */
	document.addEventListener("astro:page-load", boot);
</script>

===== END src/components/IndustryTabsPanel.astro =====

===== FILE (supporting): src/components/IndustryTabsPanel.md =====
# Industry tabs panel

A light section: a lime-dot eyebrow on the left, a big heading on the right,
a row of pill tabs, and under them one rounded 1240×520 panel split into a
photo and an ink content side — eyebrow, heading, paragraph, two stat tiles
with lime values and a lime "View solution" pill. Each tab swaps the whole
panel. Stacks below 810px.

Catalog page: `/components/industry-tabs-panel`

## Files required

**`IndustryTabsPanel.astro`** — the whole component. No imports, no
packages, no global stylesheet. One small script for the tabs and the
appear-on-scroll.

`IndustryTabsPanel.md` — this file. Not required to run.

`public/industry-tabs-panel/*.webp` — the five catalog-demo photos, passed as
props (not defaults). Optional: pass your own `image` per tab, or `src: ""` for
the labelled placeholder.

## Basic usage

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

<IndustryTabsPanel
  eyebrow="Who we move for"
  heading="Freight built around the way your industry works."
  tabs={[
    {
      label: "Automotive",
      eyebrow: "Automotive logistics",
      heading: "Parts at the line before the line asks.",
      text: "Sequenced inbound deliveries and a recovery desk for late parts.",
      stats: [
        { value: "99.6%", label: "On-time line feed" },
        { value: "12 min", label: "Median exception response" },
      ],
      image: { src: "/img/plant.webp", alt: "Robotic arms on an assembly line" },
      cta: { label: "View solution", href: "/industries/automotive" },
    },
    // ...
  ]}
/>
```

No client directive and no wrapper: it is a full-bleed section that paints
its own surface and gutters.

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `eyebrow` | `string?` | `""` | Lime-dot line left of the heading. `""` hides it (the column is kept at desktop). |
| `heading` | `string?` | `""` | The section heading. `""` hides it; the section is then named by `tabsLabel`. |
| `headingLevel` | `"h2" \| "h3"?` | `"h2"` | Panel headings are one level below. |
| `tabsLabel` | `string?` | `"Industries"` | Accessible name of the tab row. |
| `tabs` | `IndustryTab[]?` | `[]` | See below. With no tabs the component renders nothing — not even the heading or the font `<link>`. The catalog demo passes its five tabs as props. |
| `initialTab` | `number?` | `1` | Which tab starts selected, counting from 1. Clamped. |
| `background` | `string?` | `var(--nv-surface, #f8f9f6)` | The section surface. |
| `fontStylesheet` | `string?` | Manrope + Inter on Google Fonts | `""` skips the `<link>`. |
| `id` | `string?` | — | Root id. Also seeds the tab and panel ids. |

```ts
interface IndustryTab {
  label: string;                     // the pill, and the panel's name
  eyebrow?: string;                  // "" hides it
  heading: string;
  text?: string;
  stats?: { value: string; label: string }[];   // two read best
  image?: { src?: string; alt?: string; hint?: string };
  cta?: { label: string; href: string } | null;
}
```

`image.src` set to `""` (or omitted) renders the striped placeholder, naming
`hint` (or `alt`, or the label) and the size to supply: 31:26, 1240 × 1040 px.
Hrefs that are not `http(s):`, `mailto:`, `tel:` or relative are dropped.
A tab without a `label` or `heading` is dropped, as is a stat without both a
`value` and a `label`, and a `cta` without a `label`.

## Behaviour

**Tabs.** WAI-ARIA tabs with **automatic activation**: Left/Right move focus
and select in one step (wrapping), Home/End jump to the first/last, click
selects. The roving tabindex keeps only the selected tab in the Tab order;
Tab from it goes into the panel's link. Every panel is in the document, so
a swap costs nothing and there is no reason for manual activation.

**Hover (measured).** Tabs `scale(1.02) translateX(4px)`; an inactive tab's
fill goes `#e8ece5` → `#dfe7dd`; the active (ink, white text) tab gains
`box-shadow: 0 8px 16px rgba(8,17,15,.12)`. 0.3s `cubic-bezier(.22,1,.36,1)`,
applied only under `(hover: hover)`. The "View solution" pill has no measured
hover and none is added.

**Appear (measured).** The panel stage starts at `opacity 0,
translateY(22px)` and rises to rest (0.7s) once 15% of it is in view,
through one IntersectionObserver that sets `data-in`. The hidden start pose
applies only under `data-itp-ready`, set by the script, so no-JS shows the
panel at rest. On each tab change the new panel re-appears softly
(`opacity 0, translateY(10px)` → rest, 0.45s).

Once the section is in view the other tabs' lazy photos are switched to
eager so a switch does not wait on a download.

## Accessibility

- `role=tablist` (named by `tabsLabel`) / `tab` / `tabpanel`, `aria-selected`,
  `aria-controls`, `aria-labelledby` on each panel, roving `tabindex`.
- Tab pills are 34px tall as measured; a pseudo-element extends each hit
  area to 44px without changing the look.
- Visible `:focus-visible` rings on tabs (ink) and the pill (lime).
- Stat tiles are a `<ul>`; the value is `<strong>`.
- Nothing scrolls the window or moves focus on load; focus moves only in
  response to an arrow/Home/End key on the tab row.
- Forced colours: tabs get borders, the selected tab uses Highlight, panel
  and tiles get outlines.

## Reduced motion

No appear, no swap animation, no hover transform. Colour and shadow changes
on hover stay.

## Without JavaScript

The tab row is hidden and **all five panels are stacked** (a `<noscript>`
style written against `data-itp-*` attributes). Justification: without the
script the tab buttons cannot switch anything, so hiding four panels behind
dead buttons would strand their content; stacking keeps every industry
readable. With JavaScript the server already renders exactly one visible
panel, so there is no layout jump when the script boots.

## Layout

- **≥ 1200px (measured):** 120px/64px padding; eyebrow column 422px, 70px
  gap, heading 62/60.76 −2.79px; 72px to the tabs; 22px to the panel;
  panel 1240×520, radius 34, photo 620px, content padding 52/48, heading
  44/44 −1.54px, text 15/25.2 at 65% white, tiles 76px tall radius 18 at 8%
  white, pill 44px.
- **810–1199px (interpolated, no tablet capture):** heading stacks under
  the eyebrow at 52px; panel stays split 50/50 at min 480px tall.
- **< 810px:** 82px/18px padding, heading 40px, tabs wrap, panel stacks with
  the photo on top at 339:250 and radius 28.

## Images and credits

| File | Slot | Size | Credit |
| :--- | :--- | :--- | :--- |
| `public/industry-tabs-panel/automotive.webp` | Automotive tab photo | 1240×1040 | Photo: Unsplash 8gr6bObQLOI, Unsplash License |
| `public/industry-tabs-panel/retail.webp` | Retail & eCommerce tab photo | 1240×1040 | Photo: Unsplash q8kR_ie6WnI, Unsplash License |
| `public/industry-tabs-panel/healthcare.webp` | Healthcare tab photo | 1240×1040 | Photo: Unsplash 1Bsqy0meA88, Unsplash License |
| `public/industry-tabs-panel/industrial.webp` | Industrial tab photo | 1240×1040 | Photo: Unsplash c9p30E94OUU, Unsplash License |
| `public/industry-tabs-panel/food.webp` | Food & beverage tab photo | 1240×1040 | Photo: Unsplash NpNvI4ilT4A, Unsplash License |

All lazy-loaded, `object-fit: cover`, with alt text describing each photo.
The retail photo shows a third-party retailer's name on some boxes; swap it
if that matters for your client.

## Approximations

- **Font.** The dump reports **Inter** for this whole section (heading
  included), not Manrope, so it uses `--nv-font-ui`.
- **Lime.** Measured `#b7ff39` here (dots, values, pill), so the fallback
  for `--nv-lime` is `#b7ff39`, not the brief's `#c8ff38`.
- **Section surface.** Measured `rgb(248,249,246)` = `#f8f9f6`, used as the
  `--nv-surface` fallback.
- **Swap animation.** The reference re-appears "softly" on tab change; the
  distance (10px) and duration (0.45s) are judgement, as is the 0.7s ease for
  the scroll appear.
- **Tablet** is interpolated; no capture exists.
- **Phone heading** is 40px without the reference's forced line breaks.
- **Hit area.** Tab pills stay 34px as measured; the 44px target comes from
  an invisible pseudo-element.

===== END src/components/IndustryTabsPanel.md =====

===== FILE (required): public/industry-tabs-panel/automotive.webp =====
[binary asset — download https://astro.baysixmedia.com/industry-tabs-panel/automotive.webp and save it to public/industry-tabs-panel/automotive.webp]

===== FILE (required): public/industry-tabs-panel/retail.webp =====
[binary asset — download https://astro.baysixmedia.com/industry-tabs-panel/retail.webp and save it to public/industry-tabs-panel/retail.webp]

===== FILE (required): public/industry-tabs-panel/healthcare.webp =====
[binary asset — download https://astro.baysixmedia.com/industry-tabs-panel/healthcare.webp and save it to public/industry-tabs-panel/healthcare.webp]

===== FILE (required): public/industry-tabs-panel/industrial.webp =====
[binary asset — download https://astro.baysixmedia.com/industry-tabs-panel/industrial.webp and save it to public/industry-tabs-panel/industrial.webp]

===== FILE (required): public/industry-tabs-panel/food.webp =====
[binary asset — download https://astro.baysixmedia.com/industry-tabs-panel/food.webp and save it to public/industry-tabs-panel/food.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
“Industry tabs panel” from my Astro Component Library:
https://astro.baysixmedia.com/components/industry-tabs-panel/

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
6 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/IndustryTabsPanel.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/industry-tabs-panel/automotive.webp, public/industry-tabs-panel/retail.webp, public/industry-tabs-panel/healthcare.webp, public/industry-tabs-panel/industrial.webp, public/industry-tabs-panel/food.webp. Leave them behind and those URLs resolve to nothing on the other end. What each component does without them is in its README.
---
/**
 * IndustryTabsPanel
 *
 * A light section: a lime-dot eyebrow on the left and a big heading on the
 * right, a row of pill tabs, and under them one rounded panel split into a
 * photo and an ink content side (eyebrow, heading, paragraph, two stat
 * tiles, a lime "View solution" pill). Each tab swaps the whole panel.
 *
 * WAI-ARIA tabs with AUTOMATIC activation: arrow keys move focus and select
 * in one step, which is fine here because every panel is already in the
 * document and a swap costs nothing.
 *
 * No script: the tab row is hidden and all panels are shown stacked (see
 * the <noscript> block), so nothing is stranded behind a control that cannot
 * work.
 *
 * Reads --nv-* design tokens from any ancestor, with fallbacks. Never sets
 * them.
 */

export interface IndustryTabStat {
	value: string;
	label: string;
}

export interface IndustryTab {
	/** The pill label, and the panel's accessible name. */
	label: string;
	/** Small uppercase line above the panel heading. `""` hides it. */
	eyebrow?: string;
	heading: string;
	text?: string;
	/** Up to two read best; more wrap onto a new row. */
	stats?: IndustryTabStat[];
	/**
	 * The photo. `src: ""` (or no `src`) renders the labelled placeholder
	 * instead, naming `hint` (or `alt`, or the label) and the size to supply.
	 */
	image?: { src?: string; alt?: string; hint?: string };
	/** The lime pill. `null` drops it. */
	cta?: { label: string; href: string } | null;
}

export interface IndustryTabsPanelProps {
	eyebrow?: string;
	heading?: string;
	headingLevel?: "h2" | "h3";
	/** Accessible name of the tab row. */
	tabsLabel?: string;
	tabs?: IndustryTab[];
	/** Which tab starts selected, counting from 1. Clamped to the list. */
	initialTab?: number;
	/** The section surface. */
	background?: string;
	fontStylesheet?: string;
	id?: string;
}

interface Props extends IndustryTabsPanelProps {}

const {
	eyebrow = "",
	heading = "",
	headingLevel = "h2",
	tabsLabel = "Industries",
	tabs = [],
	initialTab = 1,
	background = "var(--nv-surface, #f8f9f6)",
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@400;500;600;700;800&display=swap",
	id,
} = Astro.props;

const Heading = headingLevel;
const PanelHeading = headingLevel === "h2" ? "h3" : "h4";

/*
 * A tab needs a label to click and a heading for its panel; one missing either
 * is dropped rather than drawn half-empty, as is a stat without both a value
 * and a label, or a CTA without a label. No tabs, no section: the heading alone
 * never renders.
 */
const list = tabs
	.filter((tab) => tab?.label && tab?.heading)
	.map((tab) => ({
		...tab,
		stats: tab.stats?.filter((stat) => stat?.value && stat?.label),
		cta: tab.cta?.label ? tab.cta : null,
	}));

const uid = id ?? `itp-${Math.random().toString(36).slice(2, 9)}`;
const active = Math.min(Math.max(Math.round(initialTab) - 1, 0), Math.max(list.length - 1, 0));

/* Only schemes that are links, so `javascript:` never reaches an href. */
const SAFE_SCHEME = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href: string) => (SAFE_SCHEME.test(href.trim()) ? href.trim() : undefined);

/* The supplied photos are 1240 × 1040 and fill a 620 × 520 box at desktop. */
const IMAGE_SPEC = "31:26 · 1240 × 1040 px";
---

{list.length > 0 && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{list.length > 0 && (
<section
	class="itp"
	id={id}
	style={`--itp-bg: ${background};`}
	data-itp-root
	aria-labelledby={heading ? `${uid}-heading` : undefined}
	aria-label={heading ? undefined : tabsLabel}
>
	<div class="itp-inner">
		{
			(eyebrow || heading) && (
				<div class="itp-head">
					{eyebrow ? (
						<p class="itp-eyebrow">
							<span class="itp-dot" aria-hidden="true" />
							{eyebrow}
						</p>
					) : (
						<span class="itp-eyebrow-spacer" aria-hidden="true" />
					)}
					{heading && (
						<Heading class="itp-heading" id={`${uid}-heading`}>
							{heading}
						</Heading>
					)}
				</div>
			)
		}

		<div class="itp-tabs" role="tablist" aria-label={tabsLabel} data-itp-tablist>
			{
				list.map((tab, i) => (
					<button
						type="button"
						class="itp-tab"
						role="tab"
						id={`${uid}-tab-${i}`}
						aria-controls={`${uid}-panel-${i}`}
						aria-selected={i === active ? "true" : "false"}
						tabindex={i === active ? 0 : -1}
						data-itp-tab
					>
						{tab.label}
					</button>
				))
			}
		</div>

		<div class="itp-stage" data-itp-stage>
			{
				list.map((tab, i) => {
					const href = tab.cta ? safeHref(tab.cta.href) : undefined;
					const src = tab.image?.src;
					const hint = tab.image?.hint ?? tab.image?.alt ?? `${tab.label} photo`;
					return (
						<div
							class="itp-panel"
							role="tabpanel"
							id={`${uid}-panel-${i}`}
							aria-labelledby={`${uid}-tab-${i}`}
							hidden={i !== active}
							data-itp-panel
						>
							<div class="itp-media">
								{src ? (
									<img
										src={src}
										alt={tab.image?.alt ?? ""}
										width="620"
										height="520"
										loading="lazy"
										decoding="async"
									/>
								) : (
									<div class="itp-ph" role="img" aria-label={`Image placeholder: ${hint}`}>
										<div class="itp-ph-text" aria-hidden="true">
											<span class="itp-ph-label">Image</span>
											<span class="itp-ph-hint">{hint}</span>
											<span class="itp-ph-spec">{IMAGE_SPEC}</span>
										</div>
									</div>
								)}
							</div>
							<div class="itp-content">
								{tab.eyebrow && (
									<p class="itp-kicker">
										<span class="itp-dot" aria-hidden="true" />
										{tab.eyebrow}
									</p>
								)}
								<PanelHeading class="itp-title">{tab.heading}</PanelHeading>
								{tab.text && <p class="itp-text">{tab.text}</p>}
								{tab.stats && tab.stats.length > 0 && (
									<ul class="itp-stats">
										{tab.stats.map((stat) => (
											<li class="itp-stat">
												<strong class="itp-stat-value">{stat.value}</strong>
												<span class="itp-stat-label">{stat.label}</span>
											</li>
										))}
									</ul>
								)}
								{tab.cta && href && (
									<a class="itp-cta" href={href}>
										<span>{tab.cta.label}</span>
										<svg viewBox="0 0 14 14" width="14" height="14" aria-hidden="true" focusable="false">
											<path
												d="M4 10 10 4M5 4h5v5"
												fill="none"
												stroke="currentColor"
												stroke-width="1.6"
												stroke-linecap="round"
												stroke-linejoin="round"
											/>
										</svg>
									</a>
								)}
							</div>
						</div>
					);
				})
			}
		</div>
	</div>

	<noscript>
		<style>
			/* Without the script the tabs cannot switch anything, so the row is
			   hidden and every panel is shown, stacked. Written against data-*
			   attributes because a style block nested in the markup is not
			   scoped by Astro. */
			[data-itp-root] [data-itp-tablist] {
				display: none !important;
			}
			[data-itp-root] [data-itp-panel][hidden] {
				display: flex !important;
			}
		</style>
	</noscript>
</section>
)}

<style>
	.itp {
		--itp-ink: var(--nv-ink, #08110f);
		--itp-lime: var(--nv-lime, #b7ff39);
		--itp-chip: #e8ece5;
		--itp-chip-hover: #dfe7dd;
		--itp-font: var(--nv-font-ui, Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif);
		--itp-ease: cubic-bezier(0.22, 1, 0.36, 1);

		box-sizing: border-box;
		padding: 82px 18px;
		background: var(--itp-bg);
		color: var(--itp-ink);
		font-family: var(--itp-font);
	}

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

	.itp-inner {
		max-width: 1240px;
		margin: 0 auto;
	}

	/* ---- heading row ---- */
	.itp-head {
		display: flex;
		flex-direction: column;
		gap: 20px;
		/* Measured as two spacers, 50px + 22px. */
		margin-bottom: 72px;
	}

	.itp-eyebrow,
	.itp-kicker {
		display: flex;
		align-items: center;
		margin: 0;
		font-weight: 800;
		text-transform: uppercase;
	}

	.itp-eyebrow {
		gap: 9px;
		font-size: 12px;
		line-height: 14.4px;
		letter-spacing: 1.2px;
	}

	.itp-dot {
		flex: none;
		width: 8px;
		height: 8px;
		border-radius: 100px;
		background: var(--itp-lime);
	}

	.itp-heading {
		margin: 0;
		max-width: 700px;
		font-size: 40px;
		font-weight: 700;
		line-height: 1.02;
		letter-spacing: -0.045em;
		text-wrap: balance;
	}

	.itp-eyebrow-spacer {
		display: none;
	}

	/* ---- tabs ---- */
	.itp-tabs {
		display: flex;
		flex-wrap: wrap;
		gap: 10px;
		margin-bottom: 22px;
	}

	.itp-tab {
		position: relative;
		display: inline-flex;
		align-items: center;
		justify-content: center;
		height: 34px;
		padding: 0 16px;
		border: 0;
		border-radius: 100px;
		background: var(--itp-chip);
		color: var(--itp-ink);
		font: 800 11px/13.2px var(--itp-font);
		white-space: nowrap;
		cursor: pointer;
		transition:
			transform 0.3s var(--itp-ease),
			background-color 0.3s var(--itp-ease),
			box-shadow 0.3s var(--itp-ease),
			color 0.3s var(--itp-ease);
	}

	/* The pill is 34px tall as measured; the hit area is 44px. */
	.itp-tab::before {
		content: "";
		position: absolute;
		inset: -5px 0;
	}

	.itp-tab[aria-selected="true"] {
		background: var(--itp-ink);
		color: #fff;
	}

	.itp-tab:focus-visible {
		outline: 2px solid var(--itp-ink);
		outline-offset: 3px;
	}

	@media (hover: hover) {
		.itp-tab:hover {
			transform: scale(1.02) translateX(4px);
		}

		.itp-tab[aria-selected="false"]:hover {
			background: var(--itp-chip-hover);
		}

		.itp-tab[aria-selected="true"]:hover {
			box-shadow: 0 8px 16px rgb(8 17 15 / 0.12);
		}
	}

	/* ---- panel ---- */
	.itp-stage {
		display: grid;
		gap: 16px;
	}

	.itp-panel {
		display: flex;
		flex-direction: column;
		border-radius: 28px;
		background: var(--itp-ink);
		box-shadow: 0 20px 48px rgb(8 17 15 / 0.12);
		overflow: hidden;
		color: #fff;
	}

	.itp-panel[hidden] {
		display: none;
	}

	.itp-media {
		position: relative;
		flex: none;
		aspect-ratio: 339 / 250;
		overflow: hidden;
	}

	.itp-media img {
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
	}

	.itp-ph {
		display: grid;
		place-items: center;
		width: 100%;
		height: 100%;
		padding: 16px;
		text-align: center;
		background: repeating-linear-gradient(135deg, #1a2522 0 12px, #202c29 12px 24px);
		color: rgb(247 250 247 / 0.7);
	}

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

	.itp-ph-label {
		font: 700 11px/1 var(--itp-font);
		letter-spacing: 0.1em;
		text-transform: uppercase;
	}

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

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

	.itp-content {
		display: flex;
		flex-direction: column;
		justify-content: center;
		align-items: flex-start;
		min-width: 0;
		padding: 36px clamp(22px, 8vw, 32px) 32px;
	}

	.itp-kicker {
		gap: 8px;
		margin-bottom: 20px;
		font-size: 11px;
		line-height: 13.2px;
		letter-spacing: 1.1px;
		color: #fff;
	}

	.itp-title {
		margin: 0;
		max-width: 420px;
		font-size: clamp(34px, 10.5vw, 40px);
		font-weight: 700;
		line-height: 1.05;
		letter-spacing: -0.035em;
		color: #fff;
	}

	.itp-text {
		margin: 26px 0 0;
		max-width: 440px;
		font-size: 15px;
		font-weight: 400;
		line-height: 25.2px;
		color: rgb(255 255 255 / 0.65);
	}

	.itp-stats {
		display: grid;
		grid-template-columns: repeat(2, minmax(0, 1fr));
		gap: 12px;
		width: 100%;
		max-width: 524px;
		margin: 32px 0 0;
		padding: 0;
		list-style: none;
	}

	.itp-stat {
		display: flex;
		flex-direction: column;
		justify-content: center;
		gap: 3px;
		min-height: 76px;
		padding: 16px;
		border-radius: 18px;
		background: rgb(255 255 255 / 0.08);
	}

	.itp-stat-value {
		font-size: 23px;
		font-weight: 800;
		line-height: 23px;
		color: var(--itp-lime);
	}

	.itp-stat-label {
		font-size: 12px;
		font-weight: 500;
		line-height: 14.4px;
		color: #fff;
	}

	.itp-cta {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		gap: 8px;
		height: 44px;
		margin-top: 24px;
		padding: 0 18px;
		border-radius: 100px;
		background: var(--itp-lime);
		color: var(--itp-ink);
		font: 800 12px/14.4px var(--itp-font);
		text-decoration: none;
		white-space: nowrap;
	}

	.itp-cta:focus-visible {
		outline: 2px solid var(--itp-lime);
		outline-offset: 3px;
	}

	/* ---- appear (only once the script has marked the root ready) ---- */
	.itp[data-itp-ready] .itp-stage {
		transition:
			opacity 0.7s var(--itp-ease),
			transform 0.7s var(--itp-ease);
	}

	.itp[data-itp-ready]:not([data-in]) .itp-stage {
		opacity: 0;
		transform: translateY(22px);
	}

	/* A softer re-appear on each tab change. */
	.itp-panel[data-itp-swap] {
		animation: itp-swap 0.45s var(--itp-ease) both;
	}

	@keyframes itp-swap {
		from {
			opacity: 0;
			transform: translateY(10px);
		}
		to {
			opacity: 1;
			transform: none;
		}
	}

	/* ---- tablet ---- */
	@media (min-width: 810px) {
		.itp {
			padding: 100px 40px;
		}

		.itp-heading {
			font-size: 52px;
			letter-spacing: -0.045em;
		}

		.itp-panel {
			flex-direction: row;
			min-height: 480px;
			border-radius: 34px;
		}

		.itp-media {
			flex: 0 0 50%;
			aspect-ratio: auto;
		}

		.itp-content {
			flex: 1 1 50%;
			padding: 44px 40px;
		}

		.itp-title {
			font-size: 38px;
		}
	}

	/* ---- desktop: the measured layout ---- */
	@media (min-width: 1200px) {
		.itp {
			padding: 120px 64px;
		}

		.itp-head {
			flex-direction: row;
			align-items: flex-start;
			gap: 70px;
		}

		.itp-eyebrow,
		.itp-eyebrow-spacer {
			display: flex;
			flex: 0 0 422px;
		}

		.itp-heading {
			flex: 0 1 700px;
			font-size: 62px;
			line-height: 60.76px;
			letter-spacing: -2.79px;
		}

		.itp-panel {
			height: 520px;
			min-height: 0;
		}

		.itp-media {
			flex: 0 0 620px;
		}

		.itp-content {
			padding: 52px 48px;
		}

		.itp-title {
			font-size: 44px;
			line-height: 44px;
			letter-spacing: -1.54px;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.itp-tab {
			transition:
				background-color 0.3s ease,
				box-shadow 0.3s ease,
				color 0.3s ease;
		}

		.itp-tab:hover {
			transform: none !important;
		}

		.itp[data-itp-ready] .itp-stage {
			transition: none;
		}

		.itp[data-itp-ready]:not([data-in]) .itp-stage {
			opacity: 1;
			transform: none;
		}

		.itp-panel[data-itp-swap] {
			animation: none;
		}
	}

	@media (forced-colors: active) {
		.itp-tab {
			border: 1px solid ButtonText;
		}

		.itp-tab[aria-selected="true"] {
			background: Highlight;
			color: HighlightText;
			forced-color-adjust: none;
		}

		.itp-panel,
		.itp-stat {
			border: 1px solid CanvasText;
		}

		.itp-cta {
			border: 1px solid LinkText;
		}

		.itp-dot {
			forced-color-adjust: none;
		}
	}
</style>

<script>
	/*
	 * IndustryTabsPanel controller.
	 *
	 * One closure per instance, every lookup scoped to its root, every
	 * listener and the observer on one AbortController, torn down before a
	 * client-side navigation swaps the document.
	 *
	 * Automatic activation: Left/Right/Home/End move focus AND select. The
	 * roving tabindex keeps exactly one tab in the Tab order.
	 */
	interface Instance {
		destroy(): void;
	}

	function initIndustryTabs(root: HTMLElement): Instance | undefined {
		/* Idempotent: booting twice is a no-op, not a second set of listeners. */
		if (root.dataset.itpReady === "") return undefined;

		const tabs = [...root.querySelectorAll<HTMLButtonElement>("[data-itp-tab]")];
		const panels = [...root.querySelectorAll<HTMLElement>("[data-itp-panel]")];
		const stage = root.querySelector<HTMLElement>("[data-itp-stage]");
		if (tabs.length === 0 || tabs.length !== panels.length || !stage) return undefined;

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

		let index = Math.max(
			0,
			tabs.findIndex((tab) => tab.getAttribute("aria-selected") === "true"),
		);
		const servedIndex = index;

		function select(next: number, focus: boolean) {
			const n = tabs.length;
			next = ((next % n) + n) % n;
			const changed = next !== index;

			tabs.forEach((tab, i) => {
				const on = i === next;
				tab.setAttribute("aria-selected", on ? "true" : "false");
				tab.tabIndex = on ? 0 : -1;
			});
			panels.forEach((panel, i) => {
				panel.hidden = i !== next;
				if (i !== next) panel.removeAttribute("data-itp-swap");
			});

			if (changed && !reduced.matches) {
				const panel = panels[next];
				panel.removeAttribute("data-itp-swap");
				void panel.offsetWidth;
				panel.setAttribute("data-itp-swap", "");
			}

			index = next;
			/* Focus moves only in response to a key press on the tab row. */
			if (focus) tabs[next].focus();
		}

		tabs.forEach((tab, i) => {
			tab.addEventListener("click", () => select(i, false), { signal });
			tab.addEventListener(
				"keydown",
				(event) => {
					let next: number;
					switch (event.key) {
						case "ArrowRight":
							next = i + 1;
							break;
						case "ArrowLeft":
							next = i - 1;
							break;
						case "Home":
							next = 0;
							break;
						case "End":
							next = tabs.length - 1;
							break;
						default:
							return;
					}
					event.preventDefault();
					select(next, true);
				},
				{ signal },
			);
		});

		panels.forEach((panel) =>
			panel.addEventListener("animationend", () => panel.removeAttribute("data-itp-swap"), { signal }),
		);

		/* Warm the other tabs' photos once the section is on screen, so a
		   switch does not wait on a lazy image. */
		function warmImages() {
			root.querySelectorAll<HTMLImageElement>("[data-itp-panel] img").forEach((img) => {
				img.loading = "eager";
			});
		}

		/* Appear: the hidden start state applies only under data-itp-ready,
		   so no-JS and a failed boot both show the panel at rest. */
		let observer: IntersectionObserver | undefined;
		if (reduced.matches || !("IntersectionObserver" in window)) {
			root.setAttribute("data-in", "");
			root.dataset.itpReady = "";
			warmImages();
		} else {
			root.dataset.itpReady = "";
			observer = new IntersectionObserver(
				(entries) => {
					if (entries.some((entry) => entry.isIntersecting)) {
						root.setAttribute("data-in", "");
						warmImages();
						observer?.disconnect();
					}
				},
				{ threshold: 0.15 },
			);
			observer.observe(stage);
		}

		function destroy() {
			observer?.disconnect();
			controller.abort();
			select(servedIndex, false);
			panels.forEach((panel) => panel.removeAttribute("data-itp-swap"));
			root.removeAttribute("data-in");
			delete root.dataset.itpReady;
		}

		document.addEventListener("astro:before-swap", destroy, { once: true, signal });

		return { destroy };
	}

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

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

	/* Fires on every view transition in a project using <ClientRouter />. */
	document.addEventListener("astro:page-load", boot);
</script>