Rise
import { useEffect, useId, useRef, useState } from 'react';
const TABS = [
{ label: 'Atelier', body: 'Where each piece is cut, fitted and finished by hand.' },
{ label: 'Journal', body: 'Notes from the studio, published every other Sunday.' },
{ label: 'Archive', body: 'Every collection since the first, in one place.' },
];
const css = `
.tabs-rise { width: 260px; }
.tabs-rise__list { position: relative; display: flex; justify-content: space-between; padding: 0 6px 12px; }
.tabs-rise__tab { padding: 6px 4px; border: 0; border-radius: 4px; background: none; font: inherit; font-size: 12px; font-weight: 500; letter-spacing: 0.16em; text-transform: uppercase; cursor: pointer; }
.tabs-rise__tab:focus-visible { outline: 1px solid light-dark(#5f626a, #a7abb2); outline-offset: 4px; }
/* Each letter is a column of three: muted, lit, muted. The window shows one row at a time. */
.tabs-rise__word { display: flex; height: 1.4em; line-height: 1.4em; overflow: hidden; }
.tabs-rise__ch {
display: flex;
flex-direction: column;
transform: translateY(calc(var(--tabs-rise-row) * -1.4em));
transition: transform 0.6s cubic-bezier(0.7, 0, 0.2, 1) calc(var(--tabs-rise-n) * 28ms);
}
.tabs-rise__ch > span:nth-child(odd) { color: light-dark(#5f626a, #a7abb2); }
.tabs-rise__ch > span:nth-child(2) { color: light-dark(#373a40, #f1f2f3); }
.tabs-rise__tab:hover .tabs-rise__ch > span:nth-child(odd) { color: light-dark(#42454c, #e5e6e9); }
.tabs-rise__tab[data-row='0'] .tabs-rise__ch { --tabs-rise-row: 0; }
.tabs-rise__tab[data-row='1'] .tabs-rise__ch { --tabs-rise-row: 1; }
.tabs-rise__tab[data-row='2'] .tabs-rise__ch { --tabs-rise-row: 2; }
/* Back to the first row without being seen: the lowest row and the first are the same muted letter. */
.tabs-rise__tab[data-row='0'][data-reset] .tabs-rise__ch { transition: none; }
.tabs-rise__rule { position: absolute; bottom: 0; height: 1px; background: light-dark(#373a40, #f1f2f3); transition: left 0.6s cubic-bezier(0.7, 0, 0.2, 1), width 0.6s cubic-bezier(0.7, 0, 0.2, 1); }
.tabs-rise__panel { padding: 16px 10px 0; min-height: calc(3.1em + 16px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
@media (prefers-reduced-motion: reduce) {
.tabs-rise__ch, .tabs-rise__rule { transition: none; }
}
`;
export function RiseTabs() {
const id = useId();
const [sel, setSel] = useState(0);
// The tab just left keeps rolling upward to its last row, then resets out of sight.
const [leaving, setLeaving] = useState<number | null>(null);
const [rule, setRule] = useState({ left: 0, width: 0 });
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
const measure = () => {
const t = tabs.current[sel];
if (t) setRule({ left: t.offsetLeft + 4, width: t.offsetWidth - 8 - 0.16 * 12 });
};
measure();
document.fonts?.ready.then(measure);
}, [sel]);
useEffect(() => () => clearTimeout(timer.current), []);
const select = (i: number) => {
if (i === sel) return;
clearTimeout(timer.current);
setLeaving(sel);
setSel(i);
timer.current = setTimeout(() => setLeaving(null), 600 + 28 * TABS[sel].label.length + 50);
};
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-rise">
<div className="tabs-rise__list" role="tablist" aria-label="Studio" onKeyDown={onKeyDown}>
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-rise__tab"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
aria-label={t.label}
tabIndex={sel === i ? 0 : -1}
onClick={() => select(i)}
data-row={sel === i ? 1 : leaving === i ? 2 : 0}
data-reset={leaving !== i && sel !== i ? '' : undefined}
>
<span className="tabs-rise__word" aria-hidden="true">
{[...t.label].map((c, n) => (
<span key={n} className="tabs-rise__ch" style={{ '--tabs-rise-n': n } as React.CSSProperties}>
<span>{c}</span>
<span>{c}</span>
<span>{c}</span>
</span>
))}
</span>
</button>
))}
<span className="tabs-rise__rule" style={rule} aria-hidden="true" />
</div>
{TABS.map((t, i) => (
<div key={t.label} className="tabs-rise__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 initTabsRise(el) on the mounted element instead.
<div class="tabs-rise">
<div class="tabs-rise__list" role="tablist" aria-label="Studio">
<button class="tabs-rise__tab" type="button" role="tab" id="tabs-rise-tab-0" aria-selected="true" aria-controls="tabs-rise-panel-0" aria-label="Atelier" tabindex="0" data-row="1">
<span class="tabs-rise__word" aria-hidden="true">
<span class="tabs-rise__ch" style="--tabs-rise-n:0">
<span>A</span>
<span>A</span>
<span>A</span>
</span>
<span class="tabs-rise__ch" style="--tabs-rise-n:1">
<span>t</span>
<span>t</span>
<span>t</span>
</span>
<span class="tabs-rise__ch" style="--tabs-rise-n:2">
<span>e</span>
<span>e</span>
<span>e</span>
</span>
<span class="tabs-rise__ch" style="--tabs-rise-n:3">
<span>l</span>
<span>l</span>
<span>l</span>
</span>
<span class="tabs-rise__ch" style="--tabs-rise-n:4">
<span>i</span>
<span>i</span>
<span>i</span>
</span>
<span class="tabs-rise__ch" style="--tabs-rise-n:5">
<span>e</span>
<span>e</span>
<span>e</span>
</span>
<span class="tabs-rise__ch" style="--tabs-rise-n:6">
<span>r</span>
<span>r</span>
<span>r</span>
</span>
</span>
</button>
<button class="tabs-rise__tab" type="button" role="tab" id="tabs-rise-tab-1" aria-selected="false" aria-controls="tabs-rise-panel-1" aria-label="Journal" tabindex="-1" data-row="0" data-reset="">
<span class="tabs-rise__word" aria-hidden="true">
<span class="tabs-rise__ch" style="--tabs-rise-n:0">
<span>J</span>
<span>J</span>
<span>J</span>
</span>
<span class="tabs-rise__ch" style="--tabs-rise-n:1">
<span>o</span>
<span>o</span>
<span>o</span>
</span>
<span class="tabs-rise__ch" style="--tabs-rise-n:2">
<span>u</span>
<span>u</span>
<span>u</span>
</span>
<span class="tabs-rise__ch" style="--tabs-rise-n:3">
<span>r</span>
<span>r</span>
<span>r</span>
</span>
<span class="tabs-rise__ch" style="--tabs-rise-n:4">
<span>n</span>
<span>n</span>
<span>n</span>
</span>
<span class="tabs-rise__ch" style="--tabs-rise-n:5">
<span>a</span>
<span>a</span>
<span>a</span>
</span>
<span class="tabs-rise__ch" style="--tabs-rise-n:6">
<span>l</span>
<span>l</span>
<span>l</span>
</span>
</span>
</button>
<button class="tabs-rise__tab" type="button" role="tab" id="tabs-rise-tab-2" aria-selected="false" aria-controls="tabs-rise-panel-2" aria-label="Archive" tabindex="-1" data-row="0" data-reset="">
<span class="tabs-rise__word" aria-hidden="true">
<span class="tabs-rise__ch" style="--tabs-rise-n:0">
<span>A</span>
<span>A</span>
<span>A</span>
</span>
<span class="tabs-rise__ch" style="--tabs-rise-n:1">
<span>r</span>
<span>r</span>
<span>r</span>
</span>
<span class="tabs-rise__ch" style="--tabs-rise-n:2">
<span>c</span>
<span>c</span>
<span>c</span>
</span>
<span class="tabs-rise__ch" style="--tabs-rise-n:3">
<span>h</span>
<span>h</span>
<span>h</span>
</span>
<span class="tabs-rise__ch" style="--tabs-rise-n:4">
<span>i</span>
<span>i</span>
<span>i</span>
</span>
<span class="tabs-rise__ch" style="--tabs-rise-n:5">
<span>v</span>
<span>v</span>
<span>v</span>
</span>
<span class="tabs-rise__ch" style="--tabs-rise-n:6">
<span>e</span>
<span>e</span>
<span>e</span>
</span>
</span>
</button>
<span class="tabs-rise__rule" style="left:0;width:0" aria-hidden="true"></span>
</div>
<div class="tabs-rise__panel" role="tabpanel" id="tabs-rise-panel-0" aria-labelledby="tabs-rise-tab-0">Where each piece is cut, fitted and finished by hand.</div>
<div class="tabs-rise__panel" role="tabpanel" id="tabs-rise-panel-1" aria-labelledby="tabs-rise-tab-1" hidden="">Notes from the studio, published every other Sunday.</div>
<div class="tabs-rise__panel" role="tabpanel" id="tabs-rise-panel-2" aria-labelledby="tabs-rise-tab-2" hidden="">Every collection since the first, in one place.</div>
</div>.tabs-rise { width: 260px; }
.tabs-rise__list { position: relative; display: flex; justify-content: space-between; padding: 0 6px 12px; }
.tabs-rise__tab { padding: 6px 4px; border: 0; border-radius: 4px; background: none; font: inherit; font-size: 12px; font-weight: 500; letter-spacing: 0.16em; text-transform: uppercase; cursor: pointer; }
.tabs-rise__tab:focus-visible { outline: 1px solid light-dark(#5f626a, #a7abb2); outline-offset: 4px; }
/* Each letter is a column of three: muted, lit, muted. The window shows one row at a time. */
.tabs-rise__word { display: flex; height: 1.4em; line-height: 1.4em; overflow: hidden; }
.tabs-rise__ch {
display: flex;
flex-direction: column;
transform: translateY(calc(var(--tabs-rise-row) * -1.4em));
transition: transform 0.6s cubic-bezier(0.7, 0, 0.2, 1) calc(var(--tabs-rise-n) * 28ms);
}
.tabs-rise__ch > span:nth-child(odd) { color: light-dark(#5f626a, #a7abb2); }
.tabs-rise__ch > span:nth-child(2) { color: light-dark(#373a40, #f1f2f3); }
.tabs-rise__tab:hover .tabs-rise__ch > span:nth-child(odd) { color: light-dark(#42454c, #e5e6e9); }
.tabs-rise__tab[data-row='0'] .tabs-rise__ch { --tabs-rise-row: 0; }
.tabs-rise__tab[data-row='1'] .tabs-rise__ch { --tabs-rise-row: 1; }
.tabs-rise__tab[data-row='2'] .tabs-rise__ch { --tabs-rise-row: 2; }
/* Back to the first row without being seen: the lowest row and the first are the same muted letter. */
.tabs-rise__tab[data-row='0'][data-reset] .tabs-rise__ch { transition: none; }
.tabs-rise__rule { position: absolute; bottom: 0; height: 1px; background: light-dark(#373a40, #f1f2f3); transition: left 0.6s cubic-bezier(0.7, 0, 0.2, 1), width 0.6s cubic-bezier(0.7, 0, 0.2, 1); }
.tabs-rise__panel { padding: 16px 10px 0; min-height: calc(3.1em + 16px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
@media (prefers-reduced-motion: reduce) {
.tabs-rise__ch, .tabs-rise__rule { transition: none; }
}function initTabsRise(root) {
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = root.querySelectorAll('[role="tabpanel"]');
var rule = root.querySelector('.tabs-rise__rule');
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
var leaving = null, timer;
// Each letter is a column of three rows (muted, lit, muted); data-row picks which shows.
function rows() {
tabs.forEach(function (tab, k) {
tab.setAttribute('data-row', k === sel ? 1 : k === leaving ? 2 : 0);
// Back to the first row without being seen: the lowest row and the first are the same muted letter.
if (k !== sel && k !== leaving) tab.setAttribute('data-reset', '');
else tab.removeAttribute('data-reset');
});
}
function measure() {
var t = tabs[sel];
rule.style.left = t.offsetLeft + 4 + 'px';
rule.style.width = t.offsetWidth - 8 - 0.16 * 12 + 'px';
}
function select(i) {
if (i === sel) return;
clearTimeout(timer);
// The tab just left keeps rolling upward to its last row, then resets out of sight.
leaving = sel;
var letters = tabs[sel].getAttribute('aria-label').length;
sel = i;
timer = setTimeout(function () {
leaving = null;
rows();
}, 600 + 28 * letters + 50);
rows();
measure();
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);
measure();
if (document.fonts) document.fonts.ready.then(measure);
return function () {
clearTimeout(timer);
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.tabs-rise').forEach(initTabsRise);Goal: Create a "Rise" tab set.
Visual behavior: Uppercase tabs whose letters roll up one by one into the lit word, and keep rolling upward out of the word you left rather than back down, with a hairline rule sliding under the active tab.
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.
- Each letter is a column of three stacked copies (muted, lit, muted) in a one-line window. The selected tab shows the second row; the tab just left moves on to the third, and once its letters have rolled it is reset to the first with transitions off, which looks identical.
- Each letter's transition is delayed by its position, so the word rolls left to right. The tabs carry the word as an aria-label, since the letters are decoration.
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 words change colour without rolling.
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 "Rise" tab set.
Visual behavior: Uppercase tabs whose letters roll up one by one into the lit word, and keep rolling upward out of the word you left rather than back down, with a hairline rule sliding under the active tab.
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.
- Each letter is a column of three stacked copies (muted, lit, muted) in a one-line window. The selected tab shows the second row; the tab just left moves on to the third, and once its letters have rolled it is reset to the first with transitions off, which looks identical.
- Each letter's transition is delayed by its position, so the word rolls left to right. The tabs carry the word as an aria-label, since the letters are decoration.
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 words change colour without rolling.
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
Uppercase tabs whose letters roll up one by one into the lit word, and keep rolling upward out of the word you left rather than back down, with a hairline rule sliding under the active tab.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, delay stagger
- Techniques
- transition delay stagger, css variables, text animation
- 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 words change colour without rolling.