Component library
Back to catalog

Overlapping feature tabs

A vertical tab list of features beside the artwork for the selected one, over a tinted panel that overlaps the seam.

sectiontabsmediainteractive

Open preview in a new tab

Use this component

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

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

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: "Overlapping feature tabs"
Library page: https://astro.baysixmedia.com/components/overlapping-feature-tabs/
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/OverlappingFeatureTabs.astro =====
---
/**
 * Overlapping feature tabs
 *
 * A vertical tab list of features beside the artwork for the selected one,
 * with a tinted panel running underneath both and overlapping the seam
 * between them.
 *
 * Self-contained: no imports, no packages, no global stylesheet. Every colour
 * and measurement is a `--ot-*` custom property on the root, so a wrapper can
 * restyle it without editing the file.
 */

export interface FeatureTab {
	/** Tab label, and the label of its panel for assistive tech. */
	title: string;
	/** Optional second line inside the tab. */
	description?: string;
	/** Image shown in the panel. Omit for a plain tinted placeholder. */
	image?: string;
	/** Alternative text. The panel holds nothing else, so describe it. */
	imageAlt?: string;
	/** Short mark in the tab's badge. Defaults to the tab's position. */
	icon?: string;
}

interface Props {
	/** Section heading above the tabs. `""` hides it. */
	heading?: string;
	/** Optional paragraph between the heading and the tabs. `""` hides it. */
	intro?: string;
	/** For pages where `h2` is the wrong level. */
	headingLevel?: "h1" | "h2" | "h3";
	/** The tabs. Two or more is the point; one renders as a plain panel. */
	items?: FeatureTab[];
	/** Which tab opens first, counting from 1. Clamped to the list. */
	initialTab?: number;
	/** Which side the artwork takes once there is room for two columns. */
	mediaSide?: "start" | "end";
	/** Aspect ratio of the artwork frame. */
	aspectRatio?: string;
	/** Selected-tab marker, selected badge and focus ring. */
	accent?: string;
	/** Ground behind the whole section. */
	background?: string;
	/** The overlapping panel's fill. */
	panelColor?: string;
	/** Text colour. The muted tone derives from it. */
	ink?: string;
	/** Corner radius of the panel, the artwork and the tabs. */
	radius?: string;
	/** Caps the content width and centres it. */
	maxWidth?: string;
	/** Added to the root `<section>`. */
	class?: string;
	/** Root element id. Also seeds the tab and panel ids. */
	id?: string;
}

const {
	heading = "",
	intro = "",
	headingLevel = "h2",
	items: itemsProp = [],
	initialTab = 1,
	mediaSide = "start",
	aspectRatio = "4 / 5",
	accent = "#4a35c4",
	background = "#ffffff",
	panelColor = "#f2f2f5",
	ink = "#16161d",
	radius = "14px",
	maxWidth = "78rem",
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;

// A tab with no title has no name to select it by, so it is dropped. No tabs,
// no section: a heading over an empty tab list is not a component.
const items = itemsProp.filter((item) => item && item.title);

// One id prefix per instance, so several of these can share a page without
// their tabs and panels pointing at each other's elements.
const uid = id ?? `ot-${Math.random().toString(36).slice(2, 9)}`;

// 1-based in, 0-based out, and clamped rather than trusted: an out-of-range
// value would otherwise open no tab at all.
const selected =
	Math.min(Math.max(Math.trunc(initialTab), 1), Math.max(items.length, 1)) - 1;

const style = [
	`--ot-accent:${accent}`,
	`--ot-bg:${background}`,
	`--ot-panel:${panelColor}`,
	`--ot-ink:${ink}`,
	`--ot-radius:${radius}`,
	`--ot-max:${maxWidth}`,
	`--ot-ratio:${aspectRatio}`,
].join(";");
---

{items.length > 0 && (
<section class:list={["tabs", className]} id={id} style={style} data-ot-root>
	<div class="inner" data-media={mediaSide}>
		{/* The overlapping panel: decorative, behind everything, and aligned to
		    the column grid rather than to either column's box. */}
		<div class="panel" aria-hidden="true">
			<span class="panel-fill"></span>
		</div>

		{/* Copy leads in the source, whichever side it ends up on: it puts the
		    heading first when the two columns stack, and it puts each panel
		    after the tab that controls it, so Tab out of the tab list lands on
		    the panel it just opened. Which column goes where is decided by
		    `grid-column` below, not by this order. */}
		<div class="grid">
			<div class="copy">
				{heading && <Heading class="heading">{heading}</Heading>}
				{intro && <p class="intro">{intro}</p>}

				<div
					class="tablist"
					role="tablist"
					aria-orientation="vertical"
					aria-label={heading || undefined}
				>
					{
						items.map((item, i) => (
							<button
								type="button"
								class="tab"
								id={`${uid}-tab-${i}`}
								role="tab"
								aria-selected={i === selected ? "true" : "false"}
								aria-controls={`${uid}-panel-${i}`}
								tabindex={i === selected ? 0 : -1}
								data-ot-tab
							>
								<span class="badge" aria-hidden="true">
									{item.icon ?? String(i + 1)}
								</span>
								<span class="label">
									<span class="title">{item.title}</span>
									{item.description && <span class="desc">{item.description}</span>}
								</span>
							</button>
						))
					}
				</div>
			</div>

			<div class="media" data-ot-media>
				{
					items.map((item, i) => (
						<div
							class="frame"
							id={`${uid}-panel-${i}`}
							role="tabpanel"
							aria-labelledby={`${uid}-tab-${i}`}
							tabindex="0"
							hidden={i !== selected}
							data-ot-panel
						>
							{item.image ? (
								<img
									src={item.image}
									alt={item.imageAlt ?? ""}
									loading="lazy"
									decoding="async"
								/>
							) : (
								<span class="placeholder" aria-hidden="true" />
							)}
						</div>
					))
				}
			</div>
		</div>
	</div>

	<noscript>
		<style>
			/* Without the script the tabs cannot switch anything, so show every
			   panel rather than stranding the reader on one image.

			   Written against `data-ot-*` rather than the class names: a style
			   block nested in the markup is not one of Astro's scoped ones, so
			   it ships to the page as written. `.media` and `.tab` would be
			   loose in the global namespace; these attributes are not. */
			[data-ot-root] [data-ot-panel][hidden] {
				display: block;
			}

			[data-ot-root] [data-ot-media] {
				display: grid;
				gap: 16px;
			}

			[data-ot-root] [data-ot-tab] {
				cursor: default;
			}
		</style>
	</noscript>
</section>
)}

<style>
	.tabs {
		--ot-muted: color-mix(in srgb, var(--ot-ink) 62%, transparent);
		--ot-line: color-mix(in srgb, var(--ot-ink) 10%, transparent);
		--ot-hover: color-mix(in srgb, var(--ot-ink) 5%, transparent);
		--ot-gap: 64px;

		box-sizing: border-box;
		padding: clamp(32px, 5vw, 56px) clamp(16px, 4vw, 32px);
		background: var(--ot-bg);
		color: var(--ot-ink);
		font-family:
			system-ui,
			-apple-system,
			"Segoe UI",
			Roboto,
			sans-serif;
	}

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

	.inner {
		container-type: inline-size;
		position: relative;
		max-width: var(--ot-max);
		margin-inline: auto;
		padding: 24px;
	}

	/* --- the overlapping panel --------------------------------------------- */

	.panel {
		position: absolute;
		inset: 0;
		display: grid;
		grid-template-columns: repeat(12, minmax(0, 1fr));
		/* No gap until the two columns actually exist. Eleven 64px gaps are
		   704px on their own, which is wider than a phone — the fill would run
		   off the side of a layout that is only ever one column wide here. */
		column-gap: 0;
		pointer-events: none;
	}

	.panel-fill {
		grid-column: 1 / -1;
		height: 100%;
		border-radius: var(--ot-radius);
		background: var(--ot-panel);
	}

	/* --- the two columns ---------------------------------------------------- */

	.grid {
		position: relative;
		z-index: 1;
		display: grid;
		gap: 32px;
	}

	.media {
		min-width: 0;
		/* Stacked, the frame is the full column width — and at a tall ratio a
		   770px-wide column is a 960px-tall image. Capped and centred instead,
		   which also keeps it a picture rather than a backdrop. */
		max-width: 440px;
		margin-inline: auto;
	}

	.frame {
		overflow: hidden;
		aspect-ratio: var(--ot-ratio);
		border-radius: var(--ot-radius);
		background: color-mix(in srgb, var(--ot-ink) 6%, transparent);
	}

	/* The panel is the only thing a tab reveals, so it is focusable — and a
	   focusable thing needs a visible ring. */
	.frame:focus-visible {
		outline: 3px solid var(--ot-accent);
		outline-offset: 3px;
	}

	.frame img,
	.placeholder {
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
	}

	.placeholder {
		background: repeating-linear-gradient(
			135deg,
			color-mix(in srgb, var(--ot-ink) 7%, transparent) 0 10px,
			transparent 10px 20px
		);
	}

	/* The newly shown panel arrives rather than snapping. Short enough not to
	   delay the swap; the reference has no transition at all. */
	@media (prefers-reduced-motion: no-preference) {
		.frame:not([hidden]) {
			animation: ot-in 260ms ease-out both;
		}
	}

	@keyframes ot-in {
		from {
			opacity: 0;
			transform: translateY(8px);
		}
	}

	/* --- copy ---------------------------------------------------------------- */

	.copy {
		min-width: 0;
	}

	.heading {
		margin: 0;
		font-size: clamp(1.5rem, 1.1rem + 1.6cqi, 1.875rem);
		font-weight: 700;
		line-height: 1.2;
		letter-spacing: -0.015em;
	}

	.intro {
		margin: 12px 0 0;
		max-width: 46ch;
		color: var(--ot-muted);
		font-size: 1rem;
		line-height: 1.6;
	}

	.tablist {
		display: grid;
		gap: 16px;
		margin-top: 28px;
	}

	.tab {
		position: relative;
		display: flex;
		gap: 20px;
		align-items: flex-start;
		width: 100%;
		margin: 0;
		padding: 16px;
		border: 1px solid transparent;
		border-radius: var(--ot-radius);
		background: transparent;
		color: inherit;
		font: inherit;
		text-align: start;
		cursor: pointer;
		transition:
			background-color 160ms ease,
			border-color 160ms ease,
			box-shadow 160ms ease;
	}

	.tab:hover {
		background: var(--ot-hover);
	}

	/* A real ring, which the reference does not have — its focused tab is only
	   a background tint, indistinguishable from its hover state. */
	.tab:focus-visible {
		outline: 3px solid var(--ot-accent);
		outline-offset: 2px;
	}

	.tab[aria-selected="true"] {
		border-color: var(--ot-line);
		background: #fff;
		box-shadow:
			0 1px 2px rgb(16 16 29 / 6%),
			0 10px 24px -12px rgb(16 16 29 / 22%);
	}

	/* A white card on a near-white panel is about 1.1:1 — visible, but far too
	   little on its own to carry "this is the open one". The marker is a mark
	   that is either there or not, rather than another shade of grey, and it
	   reads at 7:1 against the panel. */
	.tab::before {
		content: "";
		position: absolute;
		inset-block: 12px;
		inset-inline-start: 0;
		width: 3px;
		border-radius: 999px;
		background: var(--ot-accent);
		opacity: 0;
		transition: opacity 160ms ease;
	}

	.tab[aria-selected="true"]::before {
		opacity: 1;
	}

	.badge {
		display: grid;
		flex: none;
		place-items: center;
		width: 32px;
		height: 32px;
		/* Clamped, because a small --ot-radius would otherwise compute negative,
		   which is invalid and drops the declaration entirely. */
		border-radius: max(0px, calc(var(--ot-radius) - 5px));
		background: color-mix(in srgb, var(--ot-ink) 8%, transparent);
		color: var(--ot-muted);
		font-size: 0.875rem;
		font-weight: 600;
		font-variant-numeric: tabular-nums;
		line-height: 1;
		transition:
			background-color 160ms ease,
			color 160ms ease;
	}

	.tab[aria-selected="true"] .badge {
		background: color-mix(in srgb, var(--ot-accent) 12%, transparent);
		color: var(--ot-accent);
	}

	.label {
		display: block;
		min-width: 0;
	}

	.title {
		display: block;
		font-size: 1rem;
		font-weight: 600;
		line-height: 1.4;
	}

	.desc {
		display: block;
		margin-top: 4px;
		color: var(--ot-muted);
		font-size: 0.9375rem;
		line-height: 1.55;
	}

	@media (prefers-reduced-motion: reduce) {
		.tab,
		.tab::before,
		.badge {
			transition: none;
		}
	}

	/* --- wider containers ---------------------------------------------------- */

	@container (min-width: 620px) {
		.inner {
			padding: 40px;
		}

		.tab {
			padding: 20px;
		}
	}

	@container (min-width: 900px) {
		.inner {
			padding: 56px;
		}

		.grid {
			grid-template-columns: repeat(12, minmax(0, 1fr));
			column-gap: var(--ot-gap);
			align-items: center;
		}

		.panel {
			column-gap: var(--ot-gap);
		}

		/* Both on row 1 explicitly. Sparse auto-placement will not move its
		   cursor backwards, so the column that comes second in the source but
		   first on screen would otherwise start a second row. */
		.copy,
		.media {
			grid-row: 1;
		}

		.media {
			max-width: none;
			margin-inline: 0;
		}

		[data-media="start"] .media {
			grid-column: 1 / span 6;
		}

		[data-media="start"] .copy {
			grid-column: 8 / span 5;
		}

		[data-media="start"] .panel-fill {
			grid-column: 6 / span 7;
		}

		[data-media="end"] .copy {
			grid-column: 1 / span 5;
		}

		[data-media="end"] .media {
			grid-column: 7 / span 6;
		}

		[data-media="end"] .panel-fill {
			grid-column: 1 / span 7;
		}
	}
</style>

<script>
	// One listener set per instance. Every element is looked up inside its own
	// root, so any number of these can share a page without touching each other.
	function initOverlappingTabs(root: HTMLElement) {
		const tabs = Array.from(root.querySelectorAll<HTMLButtonElement>("[data-ot-tab]"));
		const panels = Array.from(root.querySelectorAll<HTMLElement>("[data-ot-panel]"));
		if (tabs.length < 2) return;

		function select(index: number, moveFocus: boolean) {
			tabs.forEach((tab, i) => {
				const on = i === index;
				tab.setAttribute("aria-selected", String(on));
				// Roving tabindex: the whole list is one tab stop and the arrow
				// keys move within it. The reference leaves all of its tabs as
				// separate tab stops, so Tab walks them one by one.
				tab.tabIndex = on ? 0 : -1;
			});
			panels.forEach((panel, i) => {
				panel.hidden = i !== index;
			});
			if (moveFocus) tabs[index].focus();
		}

		tabs.forEach((tab, i) => {
			// Covers Enter and Space as well: both fire click on a <button>.
			tab.addEventListener("click", () => select(i, false));

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

	document
		.querySelectorAll<HTMLElement>("[data-ot-root]")
		.forEach(initOverlappingTabs);
</script>

===== END src/components/OverlappingFeatureTabs.astro =====

===== FILE (supporting): src/components/OverlappingFeatureTabs.md =====
# Overlapping feature tabs

A vertical list of features beside the artwork for whichever one is selected,
with a tinted panel running underneath both and overlapping the seam between
them. Two columns when there is room, stacked copy-first when there is not.

Catalog page: `/components/overlapping-feature-tabs`

## Copying it into another Astro project

Copy **`OverlappingFeatureTabs.astro`**. That is the whole component.

| What it needs | Status |
| :--- | :--- |
| Imports from this catalog | none |
| npm packages | none |
| Astro built-ins (`astro:assets`, …) | none |
| Global stylesheet | none — every value is a `--ot-*` property on the root |
| Webfonts | none — system stack |
| JavaScript | **~40 lines, inline in the file** — no library |
| Bundled images | **none in the component** — the demo ships three; see below |
| Network requests | none, beyond the images you pass |
| Host requirement | Astro compiles `.astro` files |

### Exact steps

1. Copy `OverlappingFeatureTabs.astro` into your components folder, e.g.
   `src/components/OverlappingFeatureTabs.astro`.
2. Render it with your own tabs:

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

<OverlappingFeatureTabs
  heading="What you get"
  intro="One line under the heading."
  items={[
    { title: "First thing", description: "…", image: "/images/one.webp", imageAlt: "…" },
    { title: "Second thing", description: "…", image: "/images/two.webp", imageAlt: "…" },
    { title: "Third thing", description: "…", image: "/images/three.webp", imageAlt: "…" },
  ]}
/>
```

3. There is no step three. Omit `image` on an item and it draws a hatched
   placeholder rather than a broken box, so you can wire the art up later.

The section paints its own padding and centres the content, so give it the full
page width rather than nesting it in an already-padded container.

## The demo's image dependencies

The catalog demo passes three images, so copying it as-you-see-it needs these
files as well as the component:

```
public/overlapping-feature-tabs/shared-tokens-800.webp    (6 KB, 800 × 1000)
public/overlapping-feature-tabs/layered-build-800.webp   (11 KB, 800 × 1000)
public/overlapping-feature-tabs/margin-notes-800.webp     (8 KB, 800 × 1000)
```

Nothing in the `.astro` file imports them — they are passed by URL — so they
will not follow the component on their own. The catalog's source panel lists
them under **assets** for that reason, and the copy button includes them.

They are 800 × 1000, which is the component's default `aspectRatio` of `4 / 5`.
Swap in art of a different shape and set `aspectRatio` to match, or leave it and
let `object-fit: cover` crop.

### Where the demo images came from

They are **original**, generated for this catalog — no stock photography, no
third party, nothing to credit.

Each is an abstract scene built as CSS: a coloured gradient ground, a radial
glow, rounded white rectangles standing in for interface content, and bars for
type. One HTML page holds all three scenes; each was shown in turn and captured
headless at 800 × 1000, then re-encoded as WebP at quality 80.

| File | Scene | Tab it illustrates |
| :--- | :--- | :--- |
| `shared-tokens-800.webp` | Three identical cards, each swatch a shade paler than the one above | one set of tokens |
| `layered-build-800.webp` | Three tilted panels joined by short connectors with a dot at each joint | built in layers |
| `margin-notes-800.webp` | A document with a smaller note pinned over its edge | reasoning beside the code |

To regenerate or restyle them, rebuild that page and screenshot each scene at
your chosen size.

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `heading` | `string` | `""` | Heading above the tabs. Hidden when empty. |
| `intro` | `string` | `""` | Paragraph under the heading. Hidden when empty. |
| `headingLevel` | `"h1" \| "h2" \| "h3"` | `"h2"` | For pages where `h2` is wrong. |
| `items` | `FeatureTab[]` | `[]` | The tabs. See below. None renders nothing at all. |
| `initialTab` | `number` | `1` | Which tab opens first, **counting from 1**. Clamped. |
| `mediaSide` | `"start" \| "end"` | `"start"` | Which side the artwork takes, once split. |
| `aspectRatio` | `string` | `"4 / 5"` | Shape of the artwork frame. |
| `accent` | `string` | `"#4a35c4"` | Selected marker, selected badge and focus ring. |
| `background` | `string` | `"#ffffff"` | Ground behind the whole section. |
| `panelColor` | `string` | `"#f2f2f5"` | The overlapping panel's fill. |
| `ink` | `string` | `"#16161d"` | Text colour. The muted tone derives from it. |
| `radius` | `string` | `"14px"` | Corners of the panel, the artwork and the tabs. |
| `maxWidth` | `string` | `"78rem"` | Caps the content width and centres it. |
| `class` | `string` | — | Added to the root `<section>`. |
| `id` | `string` | — | Root element id. Also seeds the tab and panel ids. |

### `FeatureTab`

| Field | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `title` | `string` | — | Tab label, and the panel's accessible name. |
| `description` | `string` | — | Second line inside the tab. Omit for a one-line tab. |
| `image` | `string` | — | Artwork for this tab. Omit for the hatched placeholder. |
| `imageAlt` | `string` | `""` | Alternative text. **Set it** — see Accessibility. |
| `icon` | `string` | its position | Short mark in the badge. Pass a symbol to replace the numeral. |

Two tabs or more is the point. Pass one and the script leaves it alone: a single
tab is always the selected one, so there is nothing to switch. An item without
a `title` is dropped. Nothing is built in: the catalog demo's heading, intro and
tabs are passed as props, and with no `items` the section does not render.

## Layout

The breakpoints are **container queries**, not viewport ones, so the section
reads the width it is actually given. Drop it in a narrow column and it stacks;
give it a full-width page and it splits — the host never has to say which.

| Container width | Layout | Inner padding | Tab padding |
| :--- | :--- | :--- | :--- |
| < 620px | stacked, copy first | 24px | 16px |
| 620–899px | stacked, copy first | 40px | 20px |
| ≥ 900px | two columns | 56px | 20px |

Split, the columns sit on a **12-column grid with a 64px gap**:

| Part | Columns (default `mediaSide="start"`) |
| :--- | :--- |
| Artwork | 1 – 6 |
| Overlapping panel | 6 – 12 |
| Copy and tabs | 8 – 12 |

The panel starts one column *inside* the artwork, which is what produces the
overlap — at a 1280px viewport the artwork ends at x = 608 and the panel starts
at x = 565, so 43px of the artwork sits on top of it. `mediaSide="end"` mirrors
all three.

Both columns are pinned to `grid-row: 1`. They have to be: the copy comes first
in the source but second on screen, and sparse auto-placement will not move its
cursor backwards, so the artwork would otherwise start a second row.

### Two things that only look like details

- **The panel's column gap is zero until the section actually splits.** Eleven
  64px gaps come to 704px on their own, which is wider than a phone, and the
  fill would run off the side of a layout that is only ever one column wide.
- **The artwork is capped at 440px when stacked**, and centred. Uncapped, a
  770px-wide column at a 4:5 ratio is a 960px-tall image — a backdrop rather
  than a picture.

Stacked, the artwork sits **below** the tab list, which means tapping a tab on a
phone changes something further down the page. That is the reference's
behaviour too, and the alternative is worse: putting the artwork on top pushes
the heading off the first screen. If it matters for your content, give the
section a container under 900px wide only where the tabs are short.

## Accessibility

- **Correct tab semantics.** A `role="tablist"` with `aria-orientation="vertical"`
  holds `role="tab"` buttons carrying `aria-selected` and `aria-controls`. Each
  `role="tabpanel"` is `aria-labelledby` its own tab. Every id is unique per
  instance.
- **Roving tabindex.** The selected tab is `tabindex="0"` and the rest are
  `-1`, so the whole list is one tab stop and the arrow keys move within it.
  *The reference leaves all of its tabs as separate tab stops.*
- **Keyboard**, all verified:

  | Key | Result |
  | :--- | :--- |
  | Arrow Down / Arrow Up | Move focus to the next / previous tab **and** select it. Wraps at both ends. |
  | Home / End | First / last tab. *The reference does not support these.* |
  | Enter / Space | Selects the focused tab. |
  | Tab | Leaves the list and lands on the panel the selected tab controls. |

  Arrow, Home and End all call `preventDefault`, so End does not jump the page
  to the bottom.
- **Source order puts each panel after the tab list**, which is why Tab lands on
  the panel. The two columns are placed by `grid-column`, so the artwork still
  draws on the left.
- **Visible focus**: a 3px accent outline at a 2px offset on `:focus-visible`,
  on the tabs and on the panel. *The reference sets `outline: none` and leans on
  a background tint that is indistinguishable from its own hover state.*
- **Selection is not carried by colour alone.** A white card on a near-white
  panel measures **1.12:1** — visible, but nowhere near enough on its own. The
  selected tab also gets a 3px accent marker on its leading edge: a mark that is
  either there or not, at 7.24:1 against the panel.
- **`headingLevel`** keeps the document outline correct.
- **Contrast**, measured on the defaults with every alpha composited against
  what is actually painted underneath — the copy sits over the panel, not over
  the section's white ground:

  | | Ratio | Required |
  | :--- | :--- | :--- |
  | Heading, 30px 700, on the panel | 16.1:1 | 3:1 |
  | Intro, 16px, on the panel | 6.0:1 | 4.5:1 |
  | Unselected tab title, 16px 600 | 16.1:1 | 4.5:1 |
  | Unselected tab description, 15px | 6.0:1 | 4.5:1 |
  | Unselected badge numeral | 5.6:1 | 4.5:1 |
  | Selected tab title, on its card | 18.0:1 | 4.5:1 |
  | Selected tab description, on its card | 6.2:1 | 4.5:1 |
  | Selected badge numeral | 6.2:1 | 4.5:1 |
  | Accent marker and focus ring, on the panel | 7.2:1 | 3:1 |

  If you change `accent`, re-check the marker and the selected badge — a lighter
  accent will fail where this one passes.
- **Alt text is yours to write.** `imageAlt` defaults to `""`, but here the
  artwork is the *only* thing in the panel, so an empty string leaves that panel
  silent. Describe the image, or accept that the tab's own title is all a
  screen-reader user gets.
- **Reduced motion**, verified under emulation: with
  `prefers-reduced-motion: reduce` every transition computes to `none`, the
  panel's entrance animation computes to `none`, and switching a tab leaves
  **zero** running animations. Without the preference the tab transitions and
  the `ot-in` entrance are both live.
- **No horizontal overflow** at any width down to 320px.

### Without JavaScript

Verified with scripting disabled: the tabs cannot switch anything, so a
`<noscript>` block shows **all three panels stacked** rather than stranding the
reader on one image, and drops the tab buttons' pointer cursor so they do not
invite a click that does nothing.

Those rules are written against `[data-ot-root]` and `[data-ot-*]` attributes
rather than the class names. A `<style>` nested in the markup is not one of
Astro's scoped blocks — it ships to the page exactly as written — and `.media`
and `.tab` loose in the global namespace would be a problem for whatever else is
on the page.

The ARIA is slightly inconsistent in this mode: the panels still say `hidden`
while being displayed. That is the trade — a degraded, readable page beats a
correct one showing a single image and two dead buttons.

## Customisation

Everything visual is a custom property on the root, so a wrapper can restyle it
without touching the file:

```css
.my-tabs {
  --ot-accent: #0f766e;
  --ot-panel: #ecfdf5;
  --ot-ink: #052e25;
  --ot-radius: 4px;
  --ot-gap: 40px;
}
```

Pass `class="my-tabs"` to attach it. `accent`, `panelColor`, `ink`, `radius`,
`background` and `maxWidth` are also plain props if you would rather set them
inline. `--ot-gap` is the 12-column gap, and is the one value with no prop.

`--ot-muted`, `--ot-line` and `--ot-hover` all derive from `--ot-ink` via
`color-mix`, so changing the ink moves the whole greyscale with it.

**Multiple instances are independent.** Verified with three on one page — one
with `initialTab={2}`, one mirrored with a teal accent and a 4px radius, one
with no heading and `initialTab={99}` clamped to the last tab. Clicking a tab in
the second changed nothing in the other two, and no id collided. The script
looks every element up inside its own root, and the id prefix is per-instance.

## Notes on the reference

The idea — a vertical tab list beside the selected tab's artwork, over a tinted
panel that starts inside the artwork rather than beside it — was studied from an
isolated preview in a component gallery. **None of that component's source,
markup, utility classes, wording, images or branding came with it.** The markup,
styles, script, copy, placeholder and demo images are written or generated
here.

Five deliberate differences, each of them fixing something measured in the
reference:

- **Home and End work.** They do nothing there.
- **Roving tabindex**, so the list is one tab stop rather than three.
- **A visible focus ring.** The reference sets `outline: none`.
- **A selection marker that is not a colour.** The reference distinguishes the
  open tab by a white card on a near-white panel, and nothing else.
- **Container queries instead of viewport breakpoints**, so the section responds
  to the column it is in rather than to the window.

One deliberate addition: a short entrance animation on the panel, which the
reference does not have — its swap is an instant `display` change. It is 260ms,
and it is off entirely under `prefers-reduced-motion: reduce`.

===== END src/components/OverlappingFeatureTabs.md =====

===== FILE (required): public/overlapping-feature-tabs/shared-tokens-800.webp =====
[binary asset — download https://astro.baysixmedia.com/overlapping-feature-tabs/shared-tokens-800.webp and save it to public/overlapping-feature-tabs/shared-tokens-800.webp]

===== FILE (required): public/overlapping-feature-tabs/layered-build-800.webp =====
[binary asset — download https://astro.baysixmedia.com/overlapping-feature-tabs/layered-build-800.webp and save it to public/overlapping-feature-tabs/layered-build-800.webp]

===== FILE (required): public/overlapping-feature-tabs/margin-notes-800.webp =====
[binary asset — download https://astro.baysixmedia.com/overlapping-feature-tabs/margin-notes-800.webp and save it to public/overlapping-feature-tabs/margin-notes-800.webp]

--- VERIFICATION ---
This component is not enrolled in the library's verification system, so there is no
recorded evidence about it either way. Treat it as unverified and test it yourself.

--- TEST IN THIS PROJECT AFTER INSTALLING ---
- 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
“Overlapping feature tabs” from my Astro Component Library:
https://astro.baysixmedia.com/components/overlapping-feature-tabs/

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.

src/components/library/OverlappingFeatureTabs.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/overlapping-feature-tabs/shared-tokens-800.webp, public/overlapping-feature-tabs/layered-build-800.webp, public/overlapping-feature-tabs/margin-notes-800.webp. Leave them behind and those URLs resolve to nothing on the other end. What each component does without them is in its README.
---
/**
 * Overlapping feature tabs
 *
 * A vertical tab list of features beside the artwork for the selected one,
 * with a tinted panel running underneath both and overlapping the seam
 * between them.
 *
 * Self-contained: no imports, no packages, no global stylesheet. Every colour
 * and measurement is a `--ot-*` custom property on the root, so a wrapper can
 * restyle it without editing the file.
 */

export interface FeatureTab {
	/** Tab label, and the label of its panel for assistive tech. */
	title: string;
	/** Optional second line inside the tab. */
	description?: string;
	/** Image shown in the panel. Omit for a plain tinted placeholder. */
	image?: string;
	/** Alternative text. The panel holds nothing else, so describe it. */
	imageAlt?: string;
	/** Short mark in the tab's badge. Defaults to the tab's position. */
	icon?: string;
}

interface Props {
	/** Section heading above the tabs. `""` hides it. */
	heading?: string;
	/** Optional paragraph between the heading and the tabs. `""` hides it. */
	intro?: string;
	/** For pages where `h2` is the wrong level. */
	headingLevel?: "h1" | "h2" | "h3";
	/** The tabs. Two or more is the point; one renders as a plain panel. */
	items?: FeatureTab[];
	/** Which tab opens first, counting from 1. Clamped to the list. */
	initialTab?: number;
	/** Which side the artwork takes once there is room for two columns. */
	mediaSide?: "start" | "end";
	/** Aspect ratio of the artwork frame. */
	aspectRatio?: string;
	/** Selected-tab marker, selected badge and focus ring. */
	accent?: string;
	/** Ground behind the whole section. */
	background?: string;
	/** The overlapping panel's fill. */
	panelColor?: string;
	/** Text colour. The muted tone derives from it. */
	ink?: string;
	/** Corner radius of the panel, the artwork and the tabs. */
	radius?: string;
	/** Caps the content width and centres it. */
	maxWidth?: string;
	/** Added to the root `<section>`. */
	class?: string;
	/** Root element id. Also seeds the tab and panel ids. */
	id?: string;
}

const {
	heading = "",
	intro = "",
	headingLevel = "h2",
	items: itemsProp = [],
	initialTab = 1,
	mediaSide = "start",
	aspectRatio = "4 / 5",
	accent = "#4a35c4",
	background = "#ffffff",
	panelColor = "#f2f2f5",
	ink = "#16161d",
	radius = "14px",
	maxWidth = "78rem",
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;

// A tab with no title has no name to select it by, so it is dropped. No tabs,
// no section: a heading over an empty tab list is not a component.
const items = itemsProp.filter((item) => item && item.title);

// One id prefix per instance, so several of these can share a page without
// their tabs and panels pointing at each other's elements.
const uid = id ?? `ot-${Math.random().toString(36).slice(2, 9)}`;

// 1-based in, 0-based out, and clamped rather than trusted: an out-of-range
// value would otherwise open no tab at all.
const selected =
	Math.min(Math.max(Math.trunc(initialTab), 1), Math.max(items.length, 1)) - 1;

const style = [
	`--ot-accent:${accent}`,
	`--ot-bg:${background}`,
	`--ot-panel:${panelColor}`,
	`--ot-ink:${ink}`,
	`--ot-radius:${radius}`,
	`--ot-max:${maxWidth}`,
	`--ot-ratio:${aspectRatio}`,
].join(";");
---

{items.length > 0 && (
<section class:list={["tabs", className]} id={id} style={style} data-ot-root>
	<div class="inner" data-media={mediaSide}>
		{/* The overlapping panel: decorative, behind everything, and aligned to
		    the column grid rather than to either column's box. */}
		<div class="panel" aria-hidden="true">
			<span class="panel-fill"></span>
		</div>

		{/* Copy leads in the source, whichever side it ends up on: it puts the
		    heading first when the two columns stack, and it puts each panel
		    after the tab that controls it, so Tab out of the tab list lands on
		    the panel it just opened. Which column goes where is decided by
		    `grid-column` below, not by this order. */}
		<div class="grid">
			<div class="copy">
				{heading && <Heading class="heading">{heading}</Heading>}
				{intro && <p class="intro">{intro}</p>}

				<div
					class="tablist"
					role="tablist"
					aria-orientation="vertical"
					aria-label={heading || undefined}
				>
					{
						items.map((item, i) => (
							<button
								type="button"
								class="tab"
								id={`${uid}-tab-${i}`}
								role="tab"
								aria-selected={i === selected ? "true" : "false"}
								aria-controls={`${uid}-panel-${i}`}
								tabindex={i === selected ? 0 : -1}
								data-ot-tab
							>
								<span class="badge" aria-hidden="true">
									{item.icon ?? String(i + 1)}
								</span>
								<span class="label">
									<span class="title">{item.title}</span>
									{item.description && <span class="desc">{item.description}</span>}
								</span>
							</button>
						))
					}
				</div>
			</div>

			<div class="media" data-ot-media>
				{
					items.map((item, i) => (
						<div
							class="frame"
							id={`${uid}-panel-${i}`}
							role="tabpanel"
							aria-labelledby={`${uid}-tab-${i}`}
							tabindex="0"
							hidden={i !== selected}
							data-ot-panel
						>
							{item.image ? (
								<img
									src={item.image}
									alt={item.imageAlt ?? ""}
									loading="lazy"
									decoding="async"
								/>
							) : (
								<span class="placeholder" aria-hidden="true" />
							)}
						</div>
					))
				}
			</div>
		</div>
	</div>

	<noscript>
		<style>
			/* Without the script the tabs cannot switch anything, so show every
			   panel rather than stranding the reader on one image.

			   Written against `data-ot-*` rather than the class names: a style
			   block nested in the markup is not one of Astro's scoped ones, so
			   it ships to the page as written. `.media` and `.tab` would be
			   loose in the global namespace; these attributes are not. */
			[data-ot-root] [data-ot-panel][hidden] {
				display: block;
			}

			[data-ot-root] [data-ot-media] {
				display: grid;
				gap: 16px;
			}

			[data-ot-root] [data-ot-tab] {
				cursor: default;
			}
		</style>
	</noscript>
</section>
)}

<style>
	.tabs {
		--ot-muted: color-mix(in srgb, var(--ot-ink) 62%, transparent);
		--ot-line: color-mix(in srgb, var(--ot-ink) 10%, transparent);
		--ot-hover: color-mix(in srgb, var(--ot-ink) 5%, transparent);
		--ot-gap: 64px;

		box-sizing: border-box;
		padding: clamp(32px, 5vw, 56px) clamp(16px, 4vw, 32px);
		background: var(--ot-bg);
		color: var(--ot-ink);
		font-family:
			system-ui,
			-apple-system,
			"Segoe UI",
			Roboto,
			sans-serif;
	}

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

	.inner {
		container-type: inline-size;
		position: relative;
		max-width: var(--ot-max);
		margin-inline: auto;
		padding: 24px;
	}

	/* --- the overlapping panel --------------------------------------------- */

	.panel {
		position: absolute;
		inset: 0;
		display: grid;
		grid-template-columns: repeat(12, minmax(0, 1fr));
		/* No gap until the two columns actually exist. Eleven 64px gaps are
		   704px on their own, which is wider than a phone — the fill would run
		   off the side of a layout that is only ever one column wide here. */
		column-gap: 0;
		pointer-events: none;
	}

	.panel-fill {
		grid-column: 1 / -1;
		height: 100%;
		border-radius: var(--ot-radius);
		background: var(--ot-panel);
	}

	/* --- the two columns ---------------------------------------------------- */

	.grid {
		position: relative;
		z-index: 1;
		display: grid;
		gap: 32px;
	}

	.media {
		min-width: 0;
		/* Stacked, the frame is the full column width — and at a tall ratio a
		   770px-wide column is a 960px-tall image. Capped and centred instead,
		   which also keeps it a picture rather than a backdrop. */
		max-width: 440px;
		margin-inline: auto;
	}

	.frame {
		overflow: hidden;
		aspect-ratio: var(--ot-ratio);
		border-radius: var(--ot-radius);
		background: color-mix(in srgb, var(--ot-ink) 6%, transparent);
	}

	/* The panel is the only thing a tab reveals, so it is focusable — and a
	   focusable thing needs a visible ring. */
	.frame:focus-visible {
		outline: 3px solid var(--ot-accent);
		outline-offset: 3px;
	}

	.frame img,
	.placeholder {
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
	}

	.placeholder {
		background: repeating-linear-gradient(
			135deg,
			color-mix(in srgb, var(--ot-ink) 7%, transparent) 0 10px,
			transparent 10px 20px
		);
	}

	/* The newly shown panel arrives rather than snapping. Short enough not to
	   delay the swap; the reference has no transition at all. */
	@media (prefers-reduced-motion: no-preference) {
		.frame:not([hidden]) {
			animation: ot-in 260ms ease-out both;
		}
	}

	@keyframes ot-in {
		from {
			opacity: 0;
			transform: translateY(8px);
		}
	}

	/* --- copy ---------------------------------------------------------------- */

	.copy {
		min-width: 0;
	}

	.heading {
		margin: 0;
		font-size: clamp(1.5rem, 1.1rem + 1.6cqi, 1.875rem);
		font-weight: 700;
		line-height: 1.2;
		letter-spacing: -0.015em;
	}

	.intro {
		margin: 12px 0 0;
		max-width: 46ch;
		color: var(--ot-muted);
		font-size: 1rem;
		line-height: 1.6;
	}

	.tablist {
		display: grid;
		gap: 16px;
		margin-top: 28px;
	}

	.tab {
		position: relative;
		display: flex;
		gap: 20px;
		align-items: flex-start;
		width: 100%;
		margin: 0;
		padding: 16px;
		border: 1px solid transparent;
		border-radius: var(--ot-radius);
		background: transparent;
		color: inherit;
		font: inherit;
		text-align: start;
		cursor: pointer;
		transition:
			background-color 160ms ease,
			border-color 160ms ease,
			box-shadow 160ms ease;
	}

	.tab:hover {
		background: var(--ot-hover);
	}

	/* A real ring, which the reference does not have — its focused tab is only
	   a background tint, indistinguishable from its hover state. */
	.tab:focus-visible {
		outline: 3px solid var(--ot-accent);
		outline-offset: 2px;
	}

	.tab[aria-selected="true"] {
		border-color: var(--ot-line);
		background: #fff;
		box-shadow:
			0 1px 2px rgb(16 16 29 / 6%),
			0 10px 24px -12px rgb(16 16 29 / 22%);
	}

	/* A white card on a near-white panel is about 1.1:1 — visible, but far too
	   little on its own to carry "this is the open one". The marker is a mark
	   that is either there or not, rather than another shade of grey, and it
	   reads at 7:1 against the panel. */
	.tab::before {
		content: "";
		position: absolute;
		inset-block: 12px;
		inset-inline-start: 0;
		width: 3px;
		border-radius: 999px;
		background: var(--ot-accent);
		opacity: 0;
		transition: opacity 160ms ease;
	}

	.tab[aria-selected="true"]::before {
		opacity: 1;
	}

	.badge {
		display: grid;
		flex: none;
		place-items: center;
		width: 32px;
		height: 32px;
		/* Clamped, because a small --ot-radius would otherwise compute negative,
		   which is invalid and drops the declaration entirely. */
		border-radius: max(0px, calc(var(--ot-radius) - 5px));
		background: color-mix(in srgb, var(--ot-ink) 8%, transparent);
		color: var(--ot-muted);
		font-size: 0.875rem;
		font-weight: 600;
		font-variant-numeric: tabular-nums;
		line-height: 1;
		transition:
			background-color 160ms ease,
			color 160ms ease;
	}

	.tab[aria-selected="true"] .badge {
		background: color-mix(in srgb, var(--ot-accent) 12%, transparent);
		color: var(--ot-accent);
	}

	.label {
		display: block;
		min-width: 0;
	}

	.title {
		display: block;
		font-size: 1rem;
		font-weight: 600;
		line-height: 1.4;
	}

	.desc {
		display: block;
		margin-top: 4px;
		color: var(--ot-muted);
		font-size: 0.9375rem;
		line-height: 1.55;
	}

	@media (prefers-reduced-motion: reduce) {
		.tab,
		.tab::before,
		.badge {
			transition: none;
		}
	}

	/* --- wider containers ---------------------------------------------------- */

	@container (min-width: 620px) {
		.inner {
			padding: 40px;
		}

		.tab {
			padding: 20px;
		}
	}

	@container (min-width: 900px) {
		.inner {
			padding: 56px;
		}

		.grid {
			grid-template-columns: repeat(12, minmax(0, 1fr));
			column-gap: var(--ot-gap);
			align-items: center;
		}

		.panel {
			column-gap: var(--ot-gap);
		}

		/* Both on row 1 explicitly. Sparse auto-placement will not move its
		   cursor backwards, so the column that comes second in the source but
		   first on screen would otherwise start a second row. */
		.copy,
		.media {
			grid-row: 1;
		}

		.media {
			max-width: none;
			margin-inline: 0;
		}

		[data-media="start"] .media {
			grid-column: 1 / span 6;
		}

		[data-media="start"] .copy {
			grid-column: 8 / span 5;
		}

		[data-media="start"] .panel-fill {
			grid-column: 6 / span 7;
		}

		[data-media="end"] .copy {
			grid-column: 1 / span 5;
		}

		[data-media="end"] .media {
			grid-column: 7 / span 6;
		}

		[data-media="end"] .panel-fill {
			grid-column: 1 / span 7;
		}
	}
</style>

<script>
	// One listener set per instance. Every element is looked up inside its own
	// root, so any number of these can share a page without touching each other.
	function initOverlappingTabs(root: HTMLElement) {
		const tabs = Array.from(root.querySelectorAll<HTMLButtonElement>("[data-ot-tab]"));
		const panels = Array.from(root.querySelectorAll<HTMLElement>("[data-ot-panel]"));
		if (tabs.length < 2) return;

		function select(index: number, moveFocus: boolean) {
			tabs.forEach((tab, i) => {
				const on = i === index;
				tab.setAttribute("aria-selected", String(on));
				// Roving tabindex: the whole list is one tab stop and the arrow
				// keys move within it. The reference leaves all of its tabs as
				// separate tab stops, so Tab walks them one by one.
				tab.tabIndex = on ? 0 : -1;
			});
			panels.forEach((panel, i) => {
				panel.hidden = i !== index;
			});
			if (moveFocus) tabs[index].focus();
		}

		tabs.forEach((tab, i) => {
			// Covers Enter and Space as well: both fire click on a <button>.
			tab.addEventListener("click", () => select(i, false));

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

	document
		.querySelectorAll<HTMLElement>("[data-ot-root]")
		.forEach(initOverlappingTabs);
</script>