Three ways to get Overlapping feature tabs into one of your own projects, all pre-filled with this component's name and library page.
Start here
Component reference
Paste it into 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 the branding and content open.
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.
ChatGPT brief
Ask ChatGPT to plan a more detailed customization. Use it when you are still deciding which project the component belongs in, where on the page it sits, and what needs changing. It answers with a Claude Code prompt to run.
Read it first
Help me add this component to one of my Astro websites:
Component: Overlapping feature tabs
Library page: https://astro.baysixmedia.com/components/overlapping-feature-tabs
I want it added to:
[ENTER PROJECT, PAGE, OR ROUTE]
Place it:
[ENTER LOCATION OR SELECTOR]
Customize it for:
[ENTER BUSINESS, CONTENT, COLORS, IMAGES, OR OTHER CHANGES]
Create a complete Claude Code implementation prompt. Tell Claude to read the component's source, README, documented props, dependencies, and required assets from the library page; copy everything locally into the target project; preserve its responsive behavior, accessibility, interactions, reduced-motion behavior, and multiple-instance safety; test it at desktop, tablet, and mobile widths; run the target project's available check and build commands; report all changed files and differences from the library version; and avoid committing or deploying.
Placement prompt
Send the implementation request straight to Claude Code. Use it when you already know which project, which page and where on that page it goes. Paste it into Claude Code inside the destination project and it does the work.
Read it first
Add Overlapping feature tabs from my Astro Component Library to this Astro project.
Library page:
https://astro.baysixmedia.com/components/overlapping-feature-tabs
First read the component source, README, documented props, dependencies, and required assets from the library page. Copy the component and every required companion asset locally into this project. Do not import files from the deployed library at runtime.
Place it in:
[ENTER PAGE, FILE, OR ROUTE]
Position:
[ENTER WHERE IT SHOULD APPEAR]
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.
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 = "A library you can take apart",
intro = "Three habits that keep a component useful months after the page that needed it shipped.",
headingLevel = "h2",
items = [
{
title: "One set of tokens, every surface",
description:
"Colour, spacing and type live in one place. Change a value there and every screen that borrowed it moves with you.",
},
{
title: "Built in layers you can see",
description:
"Each section is its own file with its own styles, so you can lift one out without unpicking the three below it.",
},
{
title: "The reasoning travels with the code",
description:
"Every component carries a note on what it assumes and where it breaks, so the next person does not have to guess.",
},
],
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;
// 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(";");
---
<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>