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.