Binder
import { useId, useRef, useState } from 'react';
const TABS = [
{ label: 'Notes', title: 'Notes', lines: [88, 72, 94, 60] },
{ label: 'Tasks', title: 'Tasks', lines: [64, 80, 52, 70] },
{ label: 'Links', title: 'Links', lines: [76, 58, 84, 46] },
];
const css = `
@keyframes tabs-binder-turn { from { transform: perspective(500px) rotateY(-75deg); opacity: 0.3; } }
.tabs-binder { display: flex; align-items: flex-start; width: 260px; }
.tabs-binder__page { position: relative; flex: 1; height: 150px; padding: 14px 16px 14px 22px; border-radius: 6px 2px 2px 6px; background: light-dark(#ebedf0, #232529); box-shadow: 0 12px 24px -14px light-dark(rgba(0, 0, 0, 0.21), rgba(0, 0, 0, 0.7)); }
.tabs-binder__page::before { content: ''; position: absolute; left: 7px; top: 14px; bottom: 14px; width: 6px; background: radial-gradient(circle, light-dark(#fbfcfd, #121417) 2.5px, transparent 3px) 0 0 / 6px 20px repeat-y; }
.tabs-binder__panel { transform-origin: 0 50%; }
.tabs-binder[data-armed] .tabs-binder__panel { animation: tabs-binder-turn 0.45s cubic-bezier(0.3, 1.1, 0.5, 1); }
.tabs-binder__title { display: block; margin-bottom: 10px; color: light-dark(#373a40, #f1f2f3); font-size: 14px; font-weight: 600; }
.tabs-binder__line { display: block; height: 6px; margin-bottom: 9px; border-radius: 3px; background: light-dark(#cbced3, #393c41); }
.tabs-binder__list { display: grid; gap: 4px; padding-top: 12px; }
.tabs-binder__tab {
width: 58px;
height: 32px;
padding-left: 8px;
border: 0;
border-radius: 0 8px 8px 0;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 1px 0 0 light-dark(#dbdde1, #2e3035);
color: light-dark(#5f626a, #a7abb2);
font: inherit;
font-size: 11.5px;
font-weight: 600;
text-align: left;
cursor: pointer;
transform: translateX(-6px);
transition: transform 0.35s cubic-bezier(0.3, 1.6, 0.5, 1), background-color 0.25s, color 0.25s;
}
.tabs-binder__tab:hover { color: light-dark(#42454c, #e5e6e9); transform: translateX(-3px); }
.tabs-binder__tab[aria-selected='true'] { background: light-dark(#ebedf0, #232529); color: light-dark(#373a40, #f1f2f3); box-shadow: none; transform: translateX(0); }
.tabs-binder__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; }
@media (prefers-reduced-motion: reduce) {
.tabs-binder__tab { transition: none; }
.tabs-binder__panel { animation: none !important; }
}
`;
export function BinderTabs() {
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 = { ArrowDown: (sel + 1) % n, ArrowUp: (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-binder" data-armed={armed ? '' : undefined}>
<div className="tabs-binder__page">
{TABS.map((t, i) => (
<div key={t.label} className="tabs-binder__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
<span className="tabs-binder__title">{t.title}</span>
{t.lines.map((w, k) => (
<span key={k} className="tabs-binder__line" style={{ width: `${w}%` }} />
))}
</div>
))}
</div>
<div className="tabs-binder__list" role="tablist" aria-label="Sections" aria-orientation="vertical" onKeyDown={onKeyDown}>
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-binder__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>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsBinder(el) on the mounted element instead.
<div class="tabs-binder">
<div class="tabs-binder__page">
<div class="tabs-binder__panel" role="tabpanel" id="tabs-binder-panel-0" aria-labelledby="tabs-binder-tab-0">
<span class="tabs-binder__title">Notes</span>
<span class="tabs-binder__line" style="width:88%"></span>
<span class="tabs-binder__line" style="width:72%"></span>
<span class="tabs-binder__line" style="width:94%"></span>
<span class="tabs-binder__line" style="width:60%"></span>
</div>
<div class="tabs-binder__panel" role="tabpanel" id="tabs-binder-panel-1" aria-labelledby="tabs-binder-tab-1" hidden="">
<span class="tabs-binder__title">Tasks</span>
<span class="tabs-binder__line" style="width:64%"></span>
<span class="tabs-binder__line" style="width:80%"></span>
<span class="tabs-binder__line" style="width:52%"></span>
<span class="tabs-binder__line" style="width:70%"></span>
</div>
<div class="tabs-binder__panel" role="tabpanel" id="tabs-binder-panel-2" aria-labelledby="tabs-binder-tab-2" hidden="">
<span class="tabs-binder__title">Links</span>
<span class="tabs-binder__line" style="width:76%"></span>
<span class="tabs-binder__line" style="width:58%"></span>
<span class="tabs-binder__line" style="width:84%"></span>
<span class="tabs-binder__line" style="width:46%"></span>
</div>
</div>
<div class="tabs-binder__list" role="tablist" aria-label="Sections" aria-orientation="vertical">
<button class="tabs-binder__tab" type="button" role="tab" id="tabs-binder-tab-0" aria-selected="true" aria-controls="tabs-binder-panel-0" tabindex="0">Notes</button>
<button class="tabs-binder__tab" type="button" role="tab" id="tabs-binder-tab-1" aria-selected="false" aria-controls="tabs-binder-panel-1" tabindex="-1">Tasks</button>
<button class="tabs-binder__tab" type="button" role="tab" id="tabs-binder-tab-2" aria-selected="false" aria-controls="tabs-binder-panel-2" tabindex="-1">Links</button>
</div>
</div>@keyframes tabs-binder-turn { from { transform: perspective(500px) rotateY(-75deg); opacity: 0.3; } }
.tabs-binder { display: flex; align-items: flex-start; width: 260px; }
.tabs-binder__page { position: relative; flex: 1; height: 150px; padding: 14px 16px 14px 22px; border-radius: 6px 2px 2px 6px; background: light-dark(#ebedf0, #232529); box-shadow: 0 12px 24px -14px light-dark(rgba(0, 0, 0, 0.21), rgba(0, 0, 0, 0.7)); }
.tabs-binder__page::before { content: ''; position: absolute; left: 7px; top: 14px; bottom: 14px; width: 6px; background: radial-gradient(circle, light-dark(#fbfcfd, #121417) 2.5px, transparent 3px) 0 0 / 6px 20px repeat-y; }
.tabs-binder__panel { transform-origin: 0 50%; }
.tabs-binder[data-armed] .tabs-binder__panel { animation: tabs-binder-turn 0.45s cubic-bezier(0.3, 1.1, 0.5, 1); }
.tabs-binder__title { display: block; margin-bottom: 10px; color: light-dark(#373a40, #f1f2f3); font-size: 14px; font-weight: 600; }
.tabs-binder__line { display: block; height: 6px; margin-bottom: 9px; border-radius: 3px; background: light-dark(#cbced3, #393c41); }
.tabs-binder__list { display: grid; gap: 4px; padding-top: 12px; }
.tabs-binder__tab {
width: 58px;
height: 32px;
padding-left: 8px;
border: 0;
border-radius: 0 8px 8px 0;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 1px 0 0 light-dark(#dbdde1, #2e3035);
color: light-dark(#5f626a, #a7abb2);
font: inherit;
font-size: 11.5px;
font-weight: 600;
text-align: left;
cursor: pointer;
transform: translateX(-6px);
transition: transform 0.35s cubic-bezier(0.3, 1.6, 0.5, 1), background-color 0.25s, color 0.25s;
}
.tabs-binder__tab:hover { color: light-dark(#42454c, #e5e6e9); transform: translateX(-3px); }
.tabs-binder__tab[aria-selected='true'] { background: light-dark(#ebedf0, #232529); color: light-dark(#373a40, #f1f2f3); box-shadow: none; transform: translateX(0); }
.tabs-binder__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; }
@media (prefers-reduced-motion: reduce) {
.tabs-binder__tab { transition: none; }
.tabs-binder__panel { animation: none !important; }
}function initTabsBinder(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 = { ArrowDown: (sel + 1) % n, ArrowUp: (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-binder').forEach(initTabsBinder);Goal: Create a "Binder" tab set.
Visual behavior: A ring binder with index tabs down the edge of the page: the chosen tab pulls out and the page turns to its section.
Technique:
- The ARIA tabs pattern, vertical: a role="tablist" with aria-orientation="vertical", so Up and Down (plus Home and End) move between tabs and select as they go; buttons carry role="tab", aria-selected and aria-controls, and only the selected one is in the Tab order.
- The selected tab slides out from the edge; the panel turns in with a rotateY keyframe in perspective, hinged at the binding.
- 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 page changes without turning.
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 "Binder" tab set.
Visual behavior: A ring binder with index tabs down the edge of the page: the chosen tab pulls out and the page turns to its section.
Technique:
- The ARIA tabs pattern, vertical: a role="tablist" with aria-orientation="vertical", so Up and Down (plus Home and End) move between tabs and select as they go; buttons carry role="tab", aria-selected and aria-controls, and only the selected one is in the Tab order.
- The selected tab slides out from the edge; the panel turns in with a rotateY keyframe in perspective, hinged at the binding.
- 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 page changes without turning.
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
A ring binder with index tabs down the edge of the page: the chosen tab pulls out and the page turns to its section.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, 3d
- Techniques
- keyframes, 3d transform, perspective
- 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 page changes without turning.