Popup
import { useId, useRef, useState } from 'react';
const TABS = [
{ label: 'Forest', body: 'Chapter one: a path of paper pines that rise as the page opens.' },
{ label: 'Castle', body: 'Chapter two: towers fold up from the spine, flags and all.' },
{ label: 'Sea', body: 'Chapter three: three layers of waves and a ship between them.' },
];
const HINGE = '0.6s cubic-bezier(0.3, 1.45, 0.45, 1)';
const css = `
.tabs-popup { width: 270px; }
.tabs-popup__list { position: relative; display: flex; justify-content: center; gap: 26px; padding-top: 10px; perspective: 260px; }
/* The page the labels lie on and stand up from. */
.tabs-popup__list::after { content: ''; position: absolute; left: 4px; right: 4px; bottom: 0; height: 1px; background: light-dark(#dbdde1, #2e3035); }
.tabs-popup__tab {
position: relative;
border: 0;
padding: 4px 2px;
background: none;
font: inherit;
font-size: 17px;
font-weight: 600;
letter-spacing: -0.01em;
color: light-dark(#5f626a, #a7abb2);
cursor: pointer;
transform-origin: 50% 100%;
transform: rotateX(56deg);
transition: transform ${HINGE}, color 0.35s;
}
.tabs-popup__tab:hover { color: light-dark(#50535b, #c6c8cd); }
.tabs-popup__tab[aria-selected='true'] { transform: none; color: light-dark(#373a40, #f1f2f3); filter: drop-shadow(0 5px 3px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.5))); }
.tabs-popup__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; border-radius: 4px; }
.tabs-popup__panel { padding: 14px 4px 0; min-height: calc(3.1em + 14px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; text-align: center; }
@media (prefers-reduced-motion: reduce) {
.tabs-popup__tab { transition: color 0.2s; }
}
`;
export function PopupTabs() {
const id = useId();
const [sel, setSel] = useState(0);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const n = TABS.length;
const onKeyDown = (e: React.KeyboardEvent) => {
const next = { ArrowRight: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
setSel(next);
tabs.current[next]?.focus();
};
return (
<>
<style>
{css}
</style>
<div className="tabs-popup">
<div className="tabs-popup__list" role="tablist" aria-label="Chapters" onKeyDown={onKeyDown}>
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-popup__tab"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={sel === i ? 0 : -1}
onClick={() => setSel(i)}
>
{t.label}
</button>
))}
</div>
{TABS.map((t, i) => (
<div key={t.label} className="tabs-popup__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
{t.body}
</div>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsPopup(el) on the mounted element instead.
<div class="tabs-popup">
<div class="tabs-popup__list" role="tablist" aria-label="Chapters">
<button class="tabs-popup__tab" type="button" role="tab" id="tabs-popup-tab-0" aria-selected="true" aria-controls="tabs-popup-panel-0" tabindex="0">Forest</button>
<button class="tabs-popup__tab" type="button" role="tab" id="tabs-popup-tab-1" aria-selected="false" aria-controls="tabs-popup-panel-1" tabindex="-1">Castle</button>
<button class="tabs-popup__tab" type="button" role="tab" id="tabs-popup-tab-2" aria-selected="false" aria-controls="tabs-popup-panel-2" tabindex="-1">Sea</button>
</div>
<div class="tabs-popup__panel" role="tabpanel" id="tabs-popup-panel-0" aria-labelledby="tabs-popup-tab-0">Chapter one: a path of paper pines that rise as the page opens.</div>
<div class="tabs-popup__panel" role="tabpanel" id="tabs-popup-panel-1" aria-labelledby="tabs-popup-tab-1" hidden="">Chapter two: towers fold up from the spine, flags and all.</div>
<div class="tabs-popup__panel" role="tabpanel" id="tabs-popup-panel-2" aria-labelledby="tabs-popup-tab-2" hidden="">Chapter three: three layers of waves and a ship between them.</div>
</div>.tabs-popup { width: 270px; }
.tabs-popup__list { position: relative; display: flex; justify-content: center; gap: 26px; padding-top: 10px; perspective: 260px; }
/* The page the labels lie on and stand up from. */
.tabs-popup__list::after { content: ''; position: absolute; left: 4px; right: 4px; bottom: 0; height: 1px; background: light-dark(#dbdde1, #2e3035); }
.tabs-popup__tab {
position: relative;
border: 0;
padding: 4px 2px;
background: none;
font: inherit;
font-size: 17px;
font-weight: 600;
letter-spacing: -0.01em;
color: light-dark(#5f626a, #a7abb2);
cursor: pointer;
transform-origin: 50% 100%;
transform: rotateX(56deg);
transition: transform 0.6s cubic-bezier(0.3, 1.45, 0.45, 1), color 0.35s;
}
.tabs-popup__tab:hover { color: light-dark(#50535b, #c6c8cd); }
.tabs-popup__tab[aria-selected='true'] { transform: none; color: light-dark(#373a40, #f1f2f3); filter: drop-shadow(0 5px 3px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.5))); }
.tabs-popup__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; border-radius: 4px; }
.tabs-popup__panel { padding: 14px 4px 0; min-height: calc(3.1em + 14px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; text-align: center; }
@media (prefers-reduced-motion: reduce) {
.tabs-popup__tab { transition: color 0.2s; }
}function initTabsPopup(root) {
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = root.querySelectorAll('[role="tabpanel"]');
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
// Standing up and folding down are CSS transitions on aria-selected, hinged at each label's baseline.
function select(i) {
sel = i;
tabs.forEach(function (tab, k) {
tab.setAttribute('aria-selected', String(k === i));
tab.tabIndex = k === i ? 0 : -1;
});
panels.forEach(function (panel, k) { panel.hidden = k !== i; });
}
function onClick(e) {
var tab = e.target.closest('[role="tab"]');
if (tab) select(tabs.indexOf(tab));
}
function onKeyDown(e) {
var n = tabs.length;
var next = { ArrowRight: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
select(next);
tabs[next].focus();
}
list.addEventListener('click', onClick);
list.addEventListener('keydown', onKeyDown);
return function () {
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.tabs-popup').forEach(initTabsPopup);Goal: Create a "Popup" tab set.
Visual behavior: Chapter tabs that lie flat on the page like a closed pop-up book: the active label stands up on its hinge with a little spring, and the one you left folds back down.
Technique:
- The ARIA tabs pattern: a role="tablist" with an aria-label, buttons with role="tab", aria-selected and aria-controls, and role="tabpanel" panels labelled by their tab. Only the selected tab is in the Tab order (roving tabindex); the arrow keys, Home and End move between tabs and select as they go.
- The tab row has perspective, and each label is hinged at its baseline (transform-origin at the bottom). Inactive labels are rotated back 56 degrees so they lie on the page; the active one stands at 0 with a soft drop shadow.
- The rotation transitions with an overshoot, so the label springs upright like paper.
Accessibility: Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Decoration is aria-hidden. Under prefers-reduced-motion labels stand and fold without moving.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Popup" tab set.
Visual behavior: Chapter tabs that lie flat on the page like a closed pop-up book: the active label stands up on its hinge with a little spring, and the one you left folds back down.
Technique:
- The ARIA tabs pattern: a role="tablist" with an aria-label, buttons with role="tab", aria-selected and aria-controls, and role="tabpanel" panels labelled by their tab. Only the selected tab is in the Tab order (roving tabindex); the arrow keys, Home and End move between tabs and select as they go.
- The tab row has perspective, and each label is hinged at its baseline (transform-origin at the bottom). Inactive labels are rotated back 56 degrees so they lie on the page; the active one stands at 0 with a soft drop shadow.
- The rotation transitions with an overshoot, so the label springs upright like paper.
Accessibility: Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Decoration is aria-hidden. Under prefers-reduced-motion labels stand and fold without moving.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
Chapter tabs that lie flat on the page like a closed pop-up book: the active label stands up on its hinge with a little spring, and the one you left folds back down.
- Dependencies
- None
- Difficulty
- Beginner
- Triggers
- click, state change, 3d, spring
- Techniques
- 3d transform, perspective, css transition
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Decoration is aria-hidden. Under prefers-reduced-motion labels stand and fold without moving.