Fold
import { useId, useRef, useState } from 'react';
const TABS = [
{ label: 'Starters', items: ['Burrata, peaches', 'Charred leeks', 'Oysters'] },
{ label: 'Mains', items: ['Hake, brown butter', 'Lamb shoulder', 'Mushroom risotto'] },
{ label: 'Dessert', items: ['Olive oil cake', 'Plum sorbet'] },
];
const css = `
@keyframes tabs-fold-open { from { transform: perspective(600px) rotateX(-88deg); } 70% { transform: perspective(600px) rotateX(8deg); } to { transform: perspective(600px) rotateX(0deg); } }
@keyframes tabs-fold-shade { from { opacity: 0.7; } to { opacity: 0; } }
.tabs-fold { width: 250px; }
.tabs-fold__list { display: grid; grid-template-columns: repeat(3, 1fr); padding: 4px; border-radius: 12px 12px 0 0; background: light-dark(#ebedf0, #232529); }
.tabs-fold__tab { padding: 7px 0; border: 0; border-radius: 8px; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; transition: color 0.2s, background-color 0.2s; }
.tabs-fold__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-fold__tab[aria-selected='true'] { background: light-dark(#dbdde1, #2e3035); color: light-dark(#373a40, #f1f2f3); }
.tabs-fold__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; }
.tabs-fold__panel { position: relative; padding: 12px 16px 14px; border-radius: 0 0 12px 12px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#ebedf0, #232529); transform-origin: 50% 0; }
.tabs-fold__panel::after { content: ''; position: absolute; inset: 0; border-radius: inherit; background: linear-gradient(color-mix(in oklab, light-dark(#fbfcfd, #121417) 70%, transparent), transparent); opacity: 0; pointer-events: none; }
.tabs-fold[data-armed] .tabs-fold__panel { animation: tabs-fold-open 0.55s cubic-bezier(0.3, 1.1, 0.5, 1); }
.tabs-fold[data-armed] .tabs-fold__panel::after { animation: tabs-fold-shade 0.45s ease-out; }
.tabs-fold__item { display: flex; justify-content: space-between; padding: 5px 0; border-bottom: 1px dashed light-dark(#ebedf0, #232529); color: light-dark(#42454c, #e5e6e9); font-size: 12.5px; }
.tabs-fold__item:last-child { border-bottom: 0; }
@media (prefers-reduced-motion: reduce) {
.tabs-fold__panel, .tabs-fold__panel::after { animation: none !important; }
}
`;
export function FoldTabs() {
const id = useId();
const [sel, setSel] = useState(0);
const [armed, setArmed] = useState(false);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const select = (i: number) => {
setArmed(true);
setSel(i);
};
const onKeyDown = (e: React.KeyboardEvent) => {
const n = TABS.length;
const 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.current[next]?.focus();
};
return (
<>
<style>
{css}
</style>
<div className="tabs-fold" data-armed={armed ? '' : undefined}>
<div className="tabs-fold__list" role="tablist" aria-label="Menu" onKeyDown={onKeyDown}>
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-fold__tab"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={sel === i ? 0 : -1}
onClick={() => select(i)}
>
{t.label}
</button>
))}
</div>
{TABS.map((t, i) => (
<div key={t.label} className="tabs-fold__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
{t.items.map((it) => (
<div key={it} className="tabs-fold__item">
{it}
</div>
))}
</div>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsFold(el) on the mounted element instead.
<div class="tabs-fold">
<div class="tabs-fold__list" role="tablist" aria-label="Menu">
<button class="tabs-fold__tab" type="button" role="tab" id="tabs-fold-tab-0" aria-selected="true" aria-controls="tabs-fold-panel-0" tabindex="0">Starters</button>
<button class="tabs-fold__tab" type="button" role="tab" id="tabs-fold-tab-1" aria-selected="false" aria-controls="tabs-fold-panel-1" tabindex="-1">Mains</button>
<button class="tabs-fold__tab" type="button" role="tab" id="tabs-fold-tab-2" aria-selected="false" aria-controls="tabs-fold-panel-2" tabindex="-1">Dessert</button>
</div>
<div class="tabs-fold__panel" role="tabpanel" id="tabs-fold-panel-0" aria-labelledby="tabs-fold-tab-0">
<div class="tabs-fold__item">Burrata, peaches</div>
<div class="tabs-fold__item">Charred leeks</div>
<div class="tabs-fold__item">Oysters</div>
</div>
<div class="tabs-fold__panel" role="tabpanel" id="tabs-fold-panel-1" aria-labelledby="tabs-fold-tab-1" hidden="">
<div class="tabs-fold__item">Hake, brown butter</div>
<div class="tabs-fold__item">Lamb shoulder</div>
<div class="tabs-fold__item">Mushroom risotto</div>
</div>
<div class="tabs-fold__panel" role="tabpanel" id="tabs-fold-panel-2" aria-labelledby="tabs-fold-tab-2" hidden="">
<div class="tabs-fold__item">Olive oil cake</div>
<div class="tabs-fold__item">Plum sorbet</div>
</div>
</div>@keyframes tabs-fold-open { from { transform: perspective(600px) rotateX(-88deg); } 70% { transform: perspective(600px) rotateX(8deg); } to { transform: perspective(600px) rotateX(0deg); } }
@keyframes tabs-fold-shade { from { opacity: 0.7; } to { opacity: 0; } }
.tabs-fold { width: 250px; }
.tabs-fold__list { display: grid; grid-template-columns: repeat(3, 1fr); padding: 4px; border-radius: 12px 12px 0 0; background: light-dark(#ebedf0, #232529); }
.tabs-fold__tab { padding: 7px 0; border: 0; border-radius: 8px; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; transition: color 0.2s, background-color 0.2s; }
.tabs-fold__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-fold__tab[aria-selected='true'] { background: light-dark(#dbdde1, #2e3035); color: light-dark(#373a40, #f1f2f3); }
.tabs-fold__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; }
.tabs-fold__panel { position: relative; padding: 12px 16px 14px; border-radius: 0 0 12px 12px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#ebedf0, #232529); transform-origin: 50% 0; }
.tabs-fold__panel::after { content: ''; position: absolute; inset: 0; border-radius: inherit; background: linear-gradient(color-mix(in oklab, light-dark(#fbfcfd, #121417) 70%, transparent), transparent); opacity: 0; pointer-events: none; }
.tabs-fold[data-armed] .tabs-fold__panel { animation: tabs-fold-open 0.55s cubic-bezier(0.3, 1.1, 0.5, 1); }
.tabs-fold[data-armed] .tabs-fold__panel::after { animation: tabs-fold-shade 0.45s ease-out; }
.tabs-fold__item { display: flex; justify-content: space-between; padding: 5px 0; border-bottom: 1px dashed light-dark(#ebedf0, #232529); color: light-dark(#42454c, #e5e6e9); font-size: 12.5px; }
.tabs-fold__item:last-child { border-bottom: 0; }
@media (prefers-reduced-motion: reduce) {
.tabs-fold__panel, .tabs-fold__panel::after { animation: none !important; }
}function initTabsFold(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'; });
function select(i) {
// Armed on the first change, so nothing animates on load.
root.setAttribute('data-armed', '');
if (i === sel) return;
var prev = sel;
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-fold').forEach(initTabsFold);Goal: Create a "Fold" tab set.
Visual behavior: Menu tabs where the new list unfolds down from its top edge like a paper flap, with a shadow lifting as it opens.
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 panel animates rotateX from folded to flat in perspective, hinged at its top; an ::after shade fades out as it opens.
- A data-armed attribute is set on the first change, so nothing plays on page load.
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 the list appears without unfolding.
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 "Fold" tab set.
Visual behavior: Menu tabs where the new list unfolds down from its top edge like a paper flap, with a shadow lifting as it opens.
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 panel animates rotateX from folded to flat in perspective, hinged at its top; an ::after shade fades out as it opens.
- A data-armed attribute is set on the first change, so nothing plays on page load.
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 the list appears without unfolding.
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
Menu tabs where the new list unfolds down from its top edge like a paper flap, with a shadow lifting as it opens.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, 3d
- Techniques
- keyframes, perspective, 3d transform
- 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 the list appears without unfolding.