Your feed, with the newest work first.
Find people, projects and tags.
Twelve things you kept for later.
Your work, your bio and your links.
Chapter one
A winter studio, a borrowed press and a stack of offcuts.
import { useId, useRef, useState } from 'react';
const TABS = [
{ label: 'Origins', title: 'Chapter one', body: 'A winter studio, a borrowed press and a stack of offcuts.' },
{ label: 'Method', title: 'Chapter two', body: 'Six colours, printed by hand, one pass at a time.' },
{ label: 'Materials', title: 'Chapter three', body: 'Cotton rag paper and inks mixed on the day.' },
{ label: 'Credits', title: 'Chapter four', body: 'Everyone who lent a hand, a room or an opinion.' },
];
const css = `
.tabs-contents { display: flex; width: 260px; }
.tabs-contents__list { display: flex; flex-direction: column; width: 122px; flex: none; padding-right: 14px; box-shadow: inset -1px 0 0 light-dark(#ebedf0, #232529); }
.tabs-contents__tab {
display: flex;
align-items: center;
gap: 8px;
height: 30px;
padding: 0 4px 0 0;
border: 0;
border-radius: 4px;
background: none;
color: light-dark(#5f626a, #a7abb2);
font: inherit;
font-size: 13px;
font-weight: 500;
text-align: left;
cursor: pointer;
transition: color 0.3s;
}
.tabs-contents__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-contents__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-contents__tab:focus-visible { outline: 1px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.tabs-contents__num { width: 18px; font-family: Georgia, 'Times New Roman', serif; font-size: 13px; font-style: italic; font-weight: 400; font-variant-numeric: lining-nums tabular-nums; color: light-dark(#6d7179, #7d8087); transition: color 0.3s; }
.tabs-contents__tab[aria-selected='true'] .tabs-contents__num { color: light-dark(#373a40, #f1f2f3); }
/* The leader draws out to the chosen chapter and pushes its name along. */
.tabs-contents__leader { width: 8px; height: 1px; flex: none; background: light-dark(#cbced3, #393c41); transition: width 0.55s cubic-bezier(0.2, 0.8, 0.2, 1), background-color 0.3s; }
.tabs-contents__tab[aria-selected='true'] .tabs-contents__leader { width: 22px; background: light-dark(#373a40, #f1f2f3); }
.tabs-contents__panel { flex: 1; min-width: 0; padding: 4px 0 0 16px; }
.tabs-contents__panel > div { animation: tabs-contents-in 0.55s cubic-bezier(0.2, 0.8, 0.2, 1); }
@keyframes tabs-contents-in {
from { opacity: 0; transform: translateY(6px); filter: blur(3px); }
}
.tabs-contents__title { margin: 0 0 6px; font-family: Georgia, 'Times New Roman', serif; font-size: 15px; font-style: italic; font-weight: 400; color: light-dark(#373a40, #f1f2f3); }
.tabs-contents__body { margin: 0; color: light-dark(#50535b, #c6c8cd); font-size: 12px; line-height: 1.55; }
@media (prefers-reduced-motion: reduce) {
.tabs-contents__leader { transition: none; }
.tabs-contents__panel > div { animation: none; }
}
`;
export function ContentsTabs() {
const id = useId();
const [sel, setSel] = useState(0);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const onKeyDown = (e: React.KeyboardEvent) => {
const n = TABS.length;
const next = { ArrowDown: (sel + 1) % n, ArrowRight: (sel + 1) % n, ArrowUp: (sel + n - 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-contents">
<div className="tabs-contents__list" role="tablist" aria-label="Contents" aria-orientation="vertical" onKeyDown={onKeyDown}>
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-contents__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)}
>
<span className="tabs-contents__num" aria-hidden="true">
{String(i + 1).padStart(2, '0')}
</span>
<span className="tabs-contents__leader" aria-hidden="true" />
{t.label}
</button>
))}
</div>
{TABS.map((t, i) => (
<div key={t.label} className="tabs-contents__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
{sel === i && (
<div>
<p className="tabs-contents__title">{t.title}</p>
<p className="tabs-contents__body">{t.body}</p>
</div>
)}
</div>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsContents(el) on the mounted element instead.
<div class="tabs-contents">
<div class="tabs-contents__list" role="tablist" aria-label="Contents" aria-orientation="vertical">
<button class="tabs-contents__tab" type="button" role="tab" id="tabs-contents-tab-0" aria-selected="true" aria-controls="tabs-contents-panel-0" tabindex="0">
<span class="tabs-contents__num" aria-hidden="true">01</span>
<span class="tabs-contents__leader" aria-hidden="true"></span>
Origins
</button>
<button class="tabs-contents__tab" type="button" role="tab" id="tabs-contents-tab-1" aria-selected="false" aria-controls="tabs-contents-panel-1" tabindex="-1">
<span class="tabs-contents__num" aria-hidden="true">02</span>
<span class="tabs-contents__leader" aria-hidden="true"></span>
Method
</button>
<button class="tabs-contents__tab" type="button" role="tab" id="tabs-contents-tab-2" aria-selected="false" aria-controls="tabs-contents-panel-2" tabindex="-1">
<span class="tabs-contents__num" aria-hidden="true">03</span>
<span class="tabs-contents__leader" aria-hidden="true"></span>
Materials
</button>
<button class="tabs-contents__tab" type="button" role="tab" id="tabs-contents-tab-3" aria-selected="false" aria-controls="tabs-contents-panel-3" tabindex="-1">
<span class="tabs-contents__num" aria-hidden="true">04</span>
<span class="tabs-contents__leader" aria-hidden="true"></span>
Credits
</button>
</div>
<div class="tabs-contents__panel" role="tabpanel" id="tabs-contents-panel-0" aria-labelledby="tabs-contents-tab-0">
<div>
<p class="tabs-contents__title">Chapter one</p>
<p class="tabs-contents__body">A winter studio, a borrowed press and a stack of offcuts.</p>
</div>
</div>
<div class="tabs-contents__panel" role="tabpanel" id="tabs-contents-panel-1" aria-labelledby="tabs-contents-tab-1" hidden="">
<div>
<p class="tabs-contents__title">Chapter two</p>
<p class="tabs-contents__body">Six colours, printed by hand, one pass at a time.</p>
</div>
</div>
<div class="tabs-contents__panel" role="tabpanel" id="tabs-contents-panel-2" aria-labelledby="tabs-contents-tab-2" hidden="">
<div>
<p class="tabs-contents__title">Chapter three</p>
<p class="tabs-contents__body">Cotton rag paper and inks mixed on the day.</p>
</div>
</div>
<div class="tabs-contents__panel" role="tabpanel" id="tabs-contents-panel-3" aria-labelledby="tabs-contents-tab-3" hidden="">
<div>
<p class="tabs-contents__title">Chapter four</p>
<p class="tabs-contents__body">Everyone who lent a hand, a room or an opinion.</p>
</div>
</div>
</div>.tabs-contents { display: flex; width: 260px; }
.tabs-contents__list { display: flex; flex-direction: column; width: 122px; flex: none; padding-right: 14px; box-shadow: inset -1px 0 0 light-dark(#ebedf0, #232529); }
.tabs-contents__tab {
display: flex;
align-items: center;
gap: 8px;
height: 30px;
padding: 0 4px 0 0;
border: 0;
border-radius: 4px;
background: none;
color: light-dark(#5f626a, #a7abb2);
font: inherit;
font-size: 13px;
font-weight: 500;
text-align: left;
cursor: pointer;
transition: color 0.3s;
}
.tabs-contents__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-contents__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-contents__tab:focus-visible { outline: 1px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.tabs-contents__num { width: 18px; font-family: Georgia, 'Times New Roman', serif; font-size: 13px; font-style: italic; font-weight: 400; font-variant-numeric: lining-nums tabular-nums; color: light-dark(#6d7179, #7d8087); transition: color 0.3s; }
.tabs-contents__tab[aria-selected='true'] .tabs-contents__num { color: light-dark(#373a40, #f1f2f3); }
/* The leader draws out to the chosen chapter and pushes its name along. */
.tabs-contents__leader { width: 8px; height: 1px; flex: none; background: light-dark(#cbced3, #393c41); transition: width 0.55s cubic-bezier(0.2, 0.8, 0.2, 1), background-color 0.3s; }
.tabs-contents__tab[aria-selected='true'] .tabs-contents__leader { width: 22px; background: light-dark(#373a40, #f1f2f3); }
.tabs-contents__panel { flex: 1; min-width: 0; padding: 4px 0 0 16px; }
.tabs-contents__panel > div { animation: tabs-contents-in 0.55s cubic-bezier(0.2, 0.8, 0.2, 1); }
@keyframes tabs-contents-in {
from { opacity: 0; transform: translateY(6px); filter: blur(3px); }
}
.tabs-contents__title { margin: 0 0 6px; font-family: Georgia, 'Times New Roman', serif; font-size: 15px; font-style: italic; font-weight: 400; color: light-dark(#373a40, #f1f2f3); }
.tabs-contents__body { margin: 0; color: light-dark(#50535b, #c6c8cd); font-size: 12px; line-height: 1.55; }
@media (prefers-reduced-motion: reduce) {
.tabs-contents__leader { transition: none; }
.tabs-contents__panel > div { animation: none; }
}function initTabsContents(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'; });
// The leader line and colours follow aria-selected in CSS; un-hiding a panel replays its fade-in.
function select(i) {
if (i === sel) return;
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));
}
// A vertical list: Up and Down move through it, Left and Right too.
function onKeyDown(e) {
var n = tabs.length;
var next = { ArrowDown: (sel + 1) % n, ArrowRight: (sel + 1) % n, ArrowUp: (sel + n - 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-contents').forEach(initTabsContents);Goal: Create a "Contents" tab set.
Visual behavior: Vertical tabs set like a contents page: italic numerals, and a leader line that draws out to the chosen chapter as its page fades in.
Technique:
- The ARIA tabs pattern in a vertical list: a role="tablist" with an aria-label and aria-orientation="vertical", 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); Up, Down, Left, Right, Home and End move between tabs and select as they go.
- Each row is a numeral, a short leader and the name. The selected row's leader grows from 8 to 22px, pushing its name along, and the numeral, leader and name brighten together.
- The panel fades up from a slight blur each time it is shown.
Accessibility: Real ARIA tabs in a vertical list (aria-orientation vertical): the 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 leader and the panel change at once.
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 "Contents" tab set.
Visual behavior: Vertical tabs set like a contents page: italic numerals, and a leader line that draws out to the chosen chapter as its page fades in.
Technique:
- The ARIA tabs pattern in a vertical list: a role="tablist" with an aria-label and aria-orientation="vertical", 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); Up, Down, Left, Right, Home and End move between tabs and select as they go.
- Each row is a numeral, a short leader and the name. The selected row's leader grows from 8 to 22px, pushing its name along, and the numeral, leader and name brighten together.
- The panel fades up from a slight blur each time it is shown.
Accessibility: Real ARIA tabs in a vertical list (aria-orientation vertical): the 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 leader and the panel change at once.
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
Vertical tabs set like a contents page: italic numerals, and a leader line that draws out to the chosen chapter as its page fades in.
Real ARIA tabs in a vertical list (aria-orientation vertical): the 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 leader and the panel change at once.