Parallax
import { useId, useRef, useState } from 'react';
const PLACES = [
{ label: 'Coast', body: 'Low cliffs and long beaches. Best walked at low tide, north to south.' },
{ label: 'Valley', body: 'Orchards and old mills along the river, with a pub at every bridge.' },
{ label: 'Peaks', body: 'A two-day ridge walk with a hut halfway and snow until June.' },
];
const W = 240;
const H = 120;
// How far each layer travels per place, as a share of the view's width: the far hills barely
// move, the near ground rushes past and the words lead.
const LAYERS = [
{ name: 'far', f: 0.35, base: [84, 74, 60], amp: [4, 10, 30], freq: 0.05 },
{ name: 'mid', f: 0.7, base: [96, 92, 86], amp: [5, 12, 18], freq: 0.09 },
{ name: 'near', f: 1.3, base: [110, 108, 106], amp: [4, 8, 6], freq: 0.21 },
];
// A ridge line whose height and roughness change from one place to the next along the layer.
function ridge(f: number, base: number[], amp: number[], freq: number) {
const width = W + (PLACES.length - 1) * W * f;
let d = `M0 ${H}`;
for (let x = 0; x <= width; x += 6) {
const r = Math.min(PLACES.length - 1, Math.max(0, (x - W / 2) / (W * f)));
const i = Math.floor(r);
const t = r - i;
const j = Math.min(PLACES.length - 1, i + 1);
const b = base[i] + (base[j] - base[i]) * t;
const a = amp[i] + (amp[j] - amp[i]) * t;
const y = b - a * Math.abs(Math.sin(x * freq)) - a * 0.4 * Math.sin(x * freq * 2.3);
d += `L${x} ${y.toFixed(1)}`;
}
return { d: `${d}L${width} ${H}Z`, width };
}
const SHAPES = LAYERS.map((l) => ({ ...l, ...ridge(l.f, l.base, l.amp, l.freq) }));
const EASE = '0.9s cubic-bezier(0.65, 0, 0.35, 1)';
// The sun drifts a little against the land and climbs as the walk gets higher.
const SUN = ['translate(182px, 74px) scale(1.15)', 'translate(160px, 44px)', 'translate(140px, 26px) scale(0.65)'];
const css = `
.tabs-parallax { width: ${W}px; }
.tabs-parallax__view { position: relative; height: ${H}px; border-radius: 14px; overflow: hidden; background: light-dark(#dbdde1, #2e3035); }
.tabs-parallax__layer { position: absolute; left: 0; top: 0; height: ${H}px; transition: transform ${EASE}; }
.tabs-parallax__sky { position: absolute; left: 0; top: 0; }
.tabs-parallax__sun { fill: light-dark(#50535b, #c6c8cd); transition: transform ${EASE}; }
.tabs-parallax__far { fill: light-dark(#cbced3, #393c41); }
.tabs-parallax__mid { fill: light-dark(#ebedf0, #232529); }
.tabs-parallax__near { fill: light-dark(#fbfcfd, #121417); }
.tabs-parallax__name { position: absolute; top: 14px; margin: 0; font-size: 20px; font-weight: 700; color: light-dark(#373a40, #f1f2f3); }
.tabs-parallax__list { display: grid; grid-template-columns: repeat(${PLACES.length}, 1fr); margin-top: 6px; }
.tabs-parallax__tab { border: 0; padding: 6px 0 8px; background: none; font: inherit; font-size: 13px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.3s; }
.tabs-parallax__tab:hover, .tabs-parallax__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-parallax__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; border-radius: 6px; }
.tabs-parallax__panel { padding: 4px 0 0; min-height: calc(3.1em + 4px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
@media (prefers-reduced-motion: reduce) {
.tabs-parallax__layer, .tabs-parallax__sun { transition: none; }
}
`;
export function ParallaxTabs() {
const id = useId();
const [sel, setSel] = useState(0);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const shift = (f: number) => ({ transform: `translateX(${-sel * W * f}px)` });
const n = PLACES.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-parallax">
<div className="tabs-parallax__view" aria-hidden="true">
<svg className="tabs-parallax__sky" width={W} height={H}>
<circle className="tabs-parallax__sun" cx={0} cy={0} r={14} style={{ transform: SUN[sel] }} />
</svg>
{SHAPES.map((s) => (
<svg key={s.name} className="tabs-parallax__layer" style={shift(s.f)} width={s.width} height={H}>
<path className={`tabs-parallax__${s.name}`} d={s.d} />
</svg>
))}
<div className="tabs-parallax__layer" style={shift(1)}>
{PLACES.map((p, i) => (
<p key={p.label} className="tabs-parallax__name" style={{ left: i * W + 16 }}>
{p.label}
</p>
))}
</div>
</div>
<div className="tabs-parallax__list" role="tablist" aria-label="Walks" onKeyDown={onKeyDown}>
{PLACES.map((p, i) => (
<button
key={p.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-parallax__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)}
>
{p.label}
</button>
))}
</div>
{PLACES.map((p, i) => (
<div key={p.label} className="tabs-parallax__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
{p.body}
</div>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsParallax(el) on the mounted element instead.
<div class="tabs-parallax">
<div class="tabs-parallax__view" aria-hidden="true">
<svg class="tabs-parallax__sky" width="240" height="120">
<circle class="tabs-parallax__sun" cx="0" cy="0" r="14" style="transform:translate(182px, 74px) scale(1.15)"></circle>
</svg>
<svg class="tabs-parallax__layer" style="transform:translateX(0px)" width="408" height="120">
<path class="tabs-parallax__far" d="M0 120L0 84.0L6 81.8L12 80.2L18 79.5L24 79.7L30 80.5L36 81.4L42 82.1L48 82.4L54 82.4L60 82.5L66 81.8L72 80.8L78 80.5L84 80.9L90 81.4L96 81.6L102 81.5L108 81.1L114 81.0L120 81.4L126 81.5L132 80.1L138 79.1L144 78.3L150 77.1L156 75.2L162 72.8L168 70.6L174 69.5L180 70.3L186 73.1L192 74.2L198 73.0L204 71.0L210 66.7L216 60.9L222 54.6L228 50.0L234 49.2L240 53.4L246 61.9L252 70.8L258 66.5L264 58.1L270 46.0L276 32.9L282 22.7L288 19.1L294 26.5L300 39.8L306 55.2L312 68.4L318 65.1L324 50.9L330 35.7L336 23.7L342 18.5L348 21.4L354 31.1L360 43.8L366 55.7L372 63.8L378 64.4L384 47.6L390 32.7L396 23.6L402 22.2L408 27.6L408 120Z"></path>
</svg>
<svg class="tabs-parallax__layer" style="transform:translateX(0px)" width="576" height="120">
<path class="tabs-parallax__mid" d="M0 120L0 96.0L6 91.5L12 90.4L18 92.1L24 93.8L30 94.0L36 93.7L42 91.7L48 92.4L54 93.0L60 92.4L66 92.5L72 93.6L78 93.5L84 93.2L90 91.6L96 90.8L102 93.3L108 95.3L114 94.3L120 91.7L126 89.2L132 90.4L138 95.4L144 95.5L150 91.2L156 86.7L162 86.6L168 91.6L174 97.2L180 92.3L186 85.5L192 83.4L198 87.5L204 93.3L210 95.1L216 86.3L222 81.8L228 84.2L234 89.2L240 91.5L246 89.0L252 82.2L258 82.6L264 85.9L270 86.9L276 86.4L282 84.7L288 83.1L294 84.4L300 83.0L306 80.4L312 83.6L318 85.7L324 85.1L330 81.0L336 75.3L342 76.3L348 87.3L354 88.4L360 81.8L366 72.3L372 69.8L378 79.2L384 93.5L390 85.4L396 72.3L402 65.5L408 71.9L414 85.6L420 91.3L426 75.5L432 64.2L438 66.5L444 77.9L450 86.6L456 81.4L462 66.8L468 65.2L474 73.3L480 79.9L486 81.2L492 73.8L498 68.6L504 72.7L510 75.9L516 74.6L522 76.6L528 74.6L534 75.5L540 75.1L546 70.3L552 69.4L558 80.4L564 81.0L570 77.6L576 69.1L576 120Z"></path>
</svg>
<svg class="tabs-parallax__layer" style="transform:translateX(0px)" width="864" height="120">
<path class="tabs-parallax__near" d="M0 120L0 110.0L6 105.8L12 108.4L18 106.5L24 107.5L30 108.4L36 107.8L42 106.1L48 109.0L54 104.9L60 110.9L66 105.4L72 108.1L78 107.5L84 105.8L90 110.6L96 105.0L102 109.2L108 105.9L114 107.9L120 108.2L126 107.5L132 106.4L138 108.2L144 105.1L150 109.8L156 105.5L162 106.9L168 107.6L174 104.0L180 110.8L186 103.0L192 109.1L198 104.2L204 106.7L210 107.2L216 105.5L222 105.9L228 106.0L234 104.5L240 107.8L246 104.9L252 104.8L258 107.1L264 101.6L270 110.5L276 100.7L282 108.4L288 102.7L294 104.8L300 106.8L306 102.9L312 106.2L318 103.1L324 104.3L330 105.4L336 104.3L342 103.0L348 106.2L354 99.7L360 109.6L366 99.2L372 107.1L378 101.9L384 102.3L390 107.0L396 99.6L402 107.0L408 100.0L414 104.2L420 103.2L426 103.3L432 101.9L438 104.6L444 98.9L450 107.6L456 99.0L462 105.3L468 102.2L474 100.1L480 107.5L486 97.6L492 107.7L498 98.6L504 104.3L510 102.5L516 102.6L522 102.9L528 103.0L534 100.4L540 105.3L546 100.5L552 103.8L558 103.2L564 99.1L570 107.5L576 97.2L582 107.8L588 98.6L594 103.8L600 102.7L606 101.4L612 104.1L618 101.3L624 102.0L630 103.2L636 101.7L642 102.8L648 103.5L654 98.9L660 106.8L666 97.6L672 107.0L678 99.2L684 102.7L690 103.3L696 100.1L702 105.4L708 99.8L714 103.1L720 101.7L726 102.3L732 102.4L738 103.2L744 99.3L750 105.5L756 98.4L762 105.9L768 100.2L774 101.6L780 104.1L786 98.9L792 106.5L798 98.7L804 103.9L810 101.0L816 102.4L822 102.8L828 102.6L834 100.2L840 104.3L846 99.6L852 105.0L858 101.3L864 100.6L864 120Z"></path>
</svg>
<div class="tabs-parallax__layer" style="transform:translateX(0px)">
<p class="tabs-parallax__name" style="left:16px">Coast</p>
<p class="tabs-parallax__name" style="left:256px">Valley</p>
<p class="tabs-parallax__name" style="left:496px">Peaks</p>
</div>
</div>
<div class="tabs-parallax__list" role="tablist" aria-label="Walks">
<button class="tabs-parallax__tab" type="button" role="tab" id="tabs-parallax-tab-0" aria-selected="true" aria-controls="tabs-parallax-panel-0" tabindex="0">Coast</button>
<button class="tabs-parallax__tab" type="button" role="tab" id="tabs-parallax-tab-1" aria-selected="false" aria-controls="tabs-parallax-panel-1" tabindex="-1">Valley</button>
<button class="tabs-parallax__tab" type="button" role="tab" id="tabs-parallax-tab-2" aria-selected="false" aria-controls="tabs-parallax-panel-2" tabindex="-1">Peaks</button>
</div>
<div class="tabs-parallax__panel" role="tabpanel" id="tabs-parallax-panel-0" aria-labelledby="tabs-parallax-tab-0">Low cliffs and long beaches. Best walked at low tide, north to south.</div>
<div class="tabs-parallax__panel" role="tabpanel" id="tabs-parallax-panel-1" aria-labelledby="tabs-parallax-tab-1" hidden="">Orchards and old mills along the river, with a pub at every bridge.</div>
<div class="tabs-parallax__panel" role="tabpanel" id="tabs-parallax-panel-2" aria-labelledby="tabs-parallax-tab-2" hidden="">A two-day ridge walk with a hut halfway and snow until June.</div>
</div>.tabs-parallax { width: 240px; }
.tabs-parallax__view { position: relative; height: 120px; border-radius: 14px; overflow: hidden; background: light-dark(#dbdde1, #2e3035); }
.tabs-parallax__layer { position: absolute; left: 0; top: 0; height: 120px; transition: transform 0.9s cubic-bezier(0.65, 0, 0.35, 1); }
.tabs-parallax__sky { position: absolute; left: 0; top: 0; }
.tabs-parallax__sun { fill: light-dark(#50535b, #c6c8cd); transition: transform 0.9s cubic-bezier(0.65, 0, 0.35, 1); }
.tabs-parallax__far { fill: light-dark(#cbced3, #393c41); }
.tabs-parallax__mid { fill: light-dark(#ebedf0, #232529); }
.tabs-parallax__near { fill: light-dark(#fbfcfd, #121417); }
.tabs-parallax__name { position: absolute; top: 14px; margin: 0; font-size: 20px; font-weight: 700; color: light-dark(#373a40, #f1f2f3); }
.tabs-parallax__list { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: 6px; }
.tabs-parallax__tab { border: 0; padding: 6px 0 8px; background: none; font: inherit; font-size: 13px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.3s; }
.tabs-parallax__tab:hover, .tabs-parallax__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-parallax__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; border-radius: 6px; }
.tabs-parallax__panel { padding: 4px 0 0; min-height: calc(3.1em + 4px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
@media (prefers-reduced-motion: reduce) {
.tabs-parallax__layer, .tabs-parallax__sun { transition: none; }
}function initTabsParallax(root) {
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = Array.prototype.slice.call(root.querySelectorAll('[role="tabpanel"]'));
var n = tabs.length;
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
var W = 240;
// How far each layer travels per place, as a share of the view's width: the far hills barely
// move, the near ground rushes past and the words lead.
var SPEED = [0.35, 0.7, 1.3, 1];
// The sun drifts a little against the land and climbs as the walk gets higher.
var SUN = ['translate(182px, 74px) scale(1.15)', 'translate(160px, 44px)', 'translate(140px, 26px) scale(0.65)'];
var layers = root.querySelectorAll('.tabs-parallax__layer');
var sun = root.querySelector('.tabs-parallax__sun');
function select(i) {
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; });
// Each layer's transform transitions in the CSS, all on the same curve.
layers.forEach(function (layer, k) { layer.style.transform = 'translateX(' + -i * W * SPEED[k] + 'px)'; });
sun.style.transform = SUN[i];
}
function onClick(e) {
var tab = e.target.closest('[role="tab"]');
if (tab) select(tabs.indexOf(tab));
}
function onKeyDown(e) {
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-parallax').forEach(initTabsParallax);Goal: Create a "Parallax" tab set.
Visual behavior: Walk tabs over a landscape in layers: switching slides the far hills a little, the near ground a lot and the place name furthest, so the scene has depth.
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.
- Three SVG ridge layers are as long as the walk at their own speed, and each translates by its speed times the tab index on one shared curve, so the far hills barely move and the near ground rushes.
- The ridges change height and roughness from place to place along their length, and the sun drifts and climbs with each place.
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 scene changes without sliding.
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 "Parallax" tab set.
Visual behavior: Walk tabs over a landscape in layers: switching slides the far hills a little, the near ground a lot and the place name furthest, so the scene has depth.
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.
- Three SVG ridge layers are as long as the walk at their own speed, and each translates by its speed times the tab index on one shared curve, so the far hills barely move and the near ground rushes.
- The ridges change height and roughness from place to place along their length, and the sun drifts and climbs with each place.
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 scene changes without sliding.
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
Walk tabs over a landscape in layers: switching slides the far hills a little, the near ground a lot and the place name furthest, so the scene has depth.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change
- Techniques
- css transition, svg, transform only
- 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 scene changes without sliding.