Strips
Cliff paths, a cold sea and chips on the harbour wall.
Old beech woods with a river to follow all day.
Miles of sand, and the stars at night are something else.
Hut to hut along the ridge, with snow into June.
import { useId, useRef, useState } from 'react';
const PLACES = [
{ label: 'Coast', body: 'Cliff paths, a cold sea and chips on the harbour wall.' },
{ label: 'Forest', body: 'Old beech woods with a river to follow all day.' },
{ label: 'Dunes', body: 'Miles of sand, and the stars at night are something else.' },
{ label: 'Peaks', body: 'Hut to hut along the ridge, with snow into June.' },
];
const W = 240;
const H = 150;
const NARROW = 30;
const GAP = 4;
const WIDE = W - (PLACES.length - 1) * (NARROW + GAP);
// Where each strip sits when one of them is open.
function layout(sel: number) {
let x = 0;
return PLACES.map((_, i) => {
const w = i === sel ? WIDE : NARROW;
const at = { x, w };
x += w + GAP;
return at;
});
}
const css = `
.tabs-strips { position: relative; width: ${W}px; height: ${H}px; }
.tabs-strips__tab {
position: absolute;
top: 0;
height: ${H}px;
padding: 0;
border: 0;
border-radius: 12px;
overflow: hidden;
background: light-dark(#ebedf0, #232529);
font: inherit;
cursor: pointer;
transition: left 0.55s cubic-bezier(0.3, 1.1, 0.4, 1), width 0.55s cubic-bezier(0.3, 1.1, 0.4, 1);
}
.tabs-strips__tab:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
/* The scene is drawn at the open size and centred, so a closed strip shows a slice of its middle. */
.tabs-strips__scene { position: absolute; left: 50%; top: 0; width: ${WIDE}px; height: ${H}px; margin-left: ${-WIDE / 2}px; }
.tabs-strips__tab::after { content: ''; position: absolute; inset: 0; background: linear-gradient(to top, color-mix(in srgb, light-dark(#fbfcfd, #121417) 90%, transparent), color-mix(in srgb, light-dark(#fbfcfd, #121417) 72%, transparent) 45%, transparent 78%); }
.tabs-strips__sky { fill: light-dark(#ebedf0, #232529); }
.tabs-strips__a { fill: light-dark(#cbced3, #393c41); }
.tabs-strips__b { fill: light-dark(#6d7179, #7d8087); }
.tabs-strips__c { fill: light-dark(#50535b, #c6c8cd); }
/* Closed, the label stands on end up the strip; open, it lies flat along the bottom. */
.tabs-strips__label {
position: absolute;
left: 9px;
bottom: 10px;
z-index: 1;
font-size: 12px;
font-weight: 600;
line-height: 16px;
color: light-dark(#373a40, #f1f2f3);
white-space: nowrap;
transform-origin: 0 100%;
transform: translate(14px, 0) rotate(-90deg);
transition: transform 0.5s cubic-bezier(0.3, 1.2, 0.5, 1);
}
.tabs-strips__tab[aria-selected='true'] .tabs-strips__label { transform: none; font-size: 14px; }
.tabs-strips__panel { position: absolute; bottom: 32px; z-index: 1; width: ${WIDE - 18}px; margin: 0; font-size: 11.5px; line-height: 1.45; color: light-dark(#373a40, #f1f2f3); pointer-events: none; animation: tabs-strips-in 0.4s ease-out 0.3s backwards; }
@keyframes tabs-strips-in {
from { opacity: 0; transform: translateY(6px); }
}
@media (prefers-reduced-motion: reduce) {
.tabs-strips__tab, .tabs-strips__label { transition: none; }
.tabs-strips__panel { animation: none; }
}
`;
function Scene({ i }: { i: number }) {
return (
<svg className="tabs-strips__scene" viewBox={`0 0 ${WIDE} ${H}`} aria-hidden="true">
<rect className="tabs-strips__sky" width={WIDE} height={H} />
{i === 0 && (
<>
<circle className="tabs-strips__c" cx={92} cy={44} r={14} />
<path className="tabs-strips__a" d="M0 96H138V150H0Z" />
<path className="tabs-strips__b" d="M0 104Q17 99 34 104T69 104T103 104T138 104V150H0Z" />
<path className="tabs-strips__a" d="M0 70L26 62L38 96H0Z" />
</>
)}
{i === 1 &&
[8, 30, 52, 74, 96, 118].map((x, k) => (
<path key={x} className={k % 2 ? 'tabs-strips__b' : 'tabs-strips__a'} d={`M${x} ${118 - (k % 3) * 14}L${x + 14} ${150}H${x - 14}Z`} />
))}
{i === 2 && (
<>
<circle className="tabs-strips__c" cx={46} cy={40} r={10} />
<path className="tabs-strips__a" d="M0 98Q40 78 80 96T138 90V150H0Z" />
<path className="tabs-strips__b" d="M0 118Q50 100 100 116T138 112V150H0Z" />
</>
)}
{i === 3 && (
<>
<path className="tabs-strips__a" d="M-10 150L46 58L102 150Z" />
<path className="tabs-strips__b" d="M40 150L98 70L156 150Z" />
<path className="tabs-strips__c" d="M46 58L36 75L46 71L54 77Z M98 70L88 86L98 82L107 88Z" />
</>
)}
</svg>
);
}
export function StripsTabs() {
const id = useId();
const [sel, setSel] = useState(0);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const at = layout(sel);
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-strips">
<div role="tablist" aria-label="Places" onKeyDown={onKeyDown}>
{PLACES.map((p, i) => (
<button
key={p.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-strips__tab"
style={{ left: at[i].x, width: at[i].w }}
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)}
>
<Scene i={i} />
<span className="tabs-strips__label">{p.label}</span>
</button>
))}
</div>
{PLACES.map((p, i) => (
<p
key={p.label}
className="tabs-strips__panel"
style={{ left: at[sel].x + 9 }}
role="tabpanel"
id={`${id}-panel-${i}`}
aria-labelledby={`${id}-tab-${i}`}
hidden={sel !== i}
>
{p.body}
</p>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsStrips(el) on the mounted element instead.
<div class="tabs-strips">
<div role="tablist" aria-label="Places">
<button class="tabs-strips__tab" style="left:0;width:138px" type="button" role="tab" id="tabs-strips-tab-0" aria-selected="true" aria-controls="tabs-strips-panel-0" tabindex="0">
<svg class="tabs-strips__scene" viewBox="0 0 138 150" aria-hidden="true">
<rect class="tabs-strips__sky" width="138" height="150"></rect>
<circle class="tabs-strips__c" cx="92" cy="44" r="14"></circle>
<path class="tabs-strips__a" d="M0 96H138V150H0Z"></path>
<path class="tabs-strips__b" d="M0 104Q17 99 34 104T69 104T103 104T138 104V150H0Z"></path>
<path class="tabs-strips__a" d="M0 70L26 62L38 96H0Z"></path>
</svg>
<span class="tabs-strips__label">Coast</span>
</button>
<button class="tabs-strips__tab" style="left:142px;width:30px" type="button" role="tab" id="tabs-strips-tab-1" aria-selected="false" aria-controls="tabs-strips-panel-1" tabindex="-1">
<svg class="tabs-strips__scene" viewBox="0 0 138 150" aria-hidden="true">
<rect class="tabs-strips__sky" width="138" height="150"></rect>
<path class="tabs-strips__a" d="M8 118L22 150H-6Z"></path>
<path class="tabs-strips__b" d="M30 104L44 150H16Z"></path>
<path class="tabs-strips__a" d="M52 90L66 150H38Z"></path>
<path class="tabs-strips__b" d="M74 118L88 150H60Z"></path>
<path class="tabs-strips__a" d="M96 104L110 150H82Z"></path>
<path class="tabs-strips__b" d="M118 90L132 150H104Z"></path>
</svg>
<span class="tabs-strips__label">Forest</span>
</button>
<button class="tabs-strips__tab" style="left:176px;width:30px" type="button" role="tab" id="tabs-strips-tab-2" aria-selected="false" aria-controls="tabs-strips-panel-2" tabindex="-1">
<svg class="tabs-strips__scene" viewBox="0 0 138 150" aria-hidden="true">
<rect class="tabs-strips__sky" width="138" height="150"></rect>
<circle class="tabs-strips__c" cx="46" cy="40" r="10"></circle>
<path class="tabs-strips__a" d="M0 98Q40 78 80 96T138 90V150H0Z"></path>
<path class="tabs-strips__b" d="M0 118Q50 100 100 116T138 112V150H0Z"></path>
</svg>
<span class="tabs-strips__label">Dunes</span>
</button>
<button class="tabs-strips__tab" style="left:210px;width:30px" type="button" role="tab" id="tabs-strips-tab-3" aria-selected="false" aria-controls="tabs-strips-panel-3" tabindex="-1">
<svg class="tabs-strips__scene" viewBox="0 0 138 150" aria-hidden="true">
<rect class="tabs-strips__sky" width="138" height="150"></rect>
<path class="tabs-strips__a" d="M-10 150L46 58L102 150Z"></path>
<path class="tabs-strips__b" d="M40 150L98 70L156 150Z"></path>
<path class="tabs-strips__c" d="M46 58L36 75L46 71L54 77Z M98 70L88 86L98 82L107 88Z"></path>
</svg>
<span class="tabs-strips__label">Peaks</span>
</button>
</div>
<p class="tabs-strips__panel" style="left:9px" role="tabpanel" id="tabs-strips-panel-0" aria-labelledby="tabs-strips-tab-0">Cliff paths, a cold sea and chips on the harbour wall.</p>
<p class="tabs-strips__panel" style="left:9px" role="tabpanel" id="tabs-strips-panel-1" aria-labelledby="tabs-strips-tab-1" hidden="">Old beech woods with a river to follow all day.</p>
<p class="tabs-strips__panel" style="left:9px" role="tabpanel" id="tabs-strips-panel-2" aria-labelledby="tabs-strips-tab-2" hidden="">Miles of sand, and the stars at night are something else.</p>
<p class="tabs-strips__panel" style="left:9px" role="tabpanel" id="tabs-strips-panel-3" aria-labelledby="tabs-strips-tab-3" hidden="">Hut to hut along the ridge, with snow into June.</p>
</div>.tabs-strips { position: relative; width: 240px; height: 150px; }
.tabs-strips__tab {
position: absolute;
top: 0;
height: 150px;
padding: 0;
border: 0;
border-radius: 12px;
overflow: hidden;
background: light-dark(#ebedf0, #232529);
font: inherit;
cursor: pointer;
transition: left 0.55s cubic-bezier(0.3, 1.1, 0.4, 1), width 0.55s cubic-bezier(0.3, 1.1, 0.4, 1);
}
.tabs-strips__tab:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
/* The scene is drawn at the open size and centred, so a closed strip shows a slice of its middle. */
.tabs-strips__scene { position: absolute; left: 50%; top: 0; width: 138px; height: 150px; margin-left: -69px; }
.tabs-strips__tab::after { content: ''; position: absolute; inset: 0; background: linear-gradient(to top, color-mix(in srgb, light-dark(#fbfcfd, #121417) 90%, transparent), color-mix(in srgb, light-dark(#fbfcfd, #121417) 72%, transparent) 45%, transparent 78%); }
.tabs-strips__sky { fill: light-dark(#ebedf0, #232529); }
.tabs-strips__a { fill: light-dark(#cbced3, #393c41); }
.tabs-strips__b { fill: light-dark(#6d7179, #7d8087); }
.tabs-strips__c { fill: light-dark(#50535b, #c6c8cd); }
/* Closed, the label stands on end up the strip; open, it lies flat along the bottom. */
.tabs-strips__label {
position: absolute;
left: 9px;
bottom: 10px;
z-index: 1;
font-size: 12px;
font-weight: 600;
line-height: 16px;
color: light-dark(#373a40, #f1f2f3);
white-space: nowrap;
transform-origin: 0 100%;
transform: translate(14px, 0) rotate(-90deg);
transition: transform 0.5s cubic-bezier(0.3, 1.2, 0.5, 1);
}
.tabs-strips__tab[aria-selected='true'] .tabs-strips__label { transform: none; font-size: 14px; }
.tabs-strips__panel { position: absolute; bottom: 32px; z-index: 1; width: 120px; margin: 0; font-size: 11.5px; line-height: 1.45; color: light-dark(#373a40, #f1f2f3); pointer-events: none; animation: tabs-strips-in 0.4s ease-out 0.3s backwards; }
@keyframes tabs-strips-in {
from { opacity: 0; transform: translateY(6px); }
}
@media (prefers-reduced-motion: reduce) {
.tabs-strips__tab, .tabs-strips__label { transition: none; }
.tabs-strips__panel { animation: none; }
}function initTabsStrips(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;
var NARROW = 30;
var GAP = 4;
var WIDE = W - (n - 1) * (NARROW + GAP);
// The open strip is wide and the rest narrow; each strip's left and width transition in the CSS.
function layout() {
var x = 0;
tabs.forEach(function (tab, k) {
var w = k === sel ? WIDE : NARROW;
tab.style.left = x + 'px';
tab.style.width = w + 'px';
if (k === sel) panels.forEach(function (p) { p.style.left = x + 9 + 'px'; });
x += w + GAP;
});
}
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; });
layout();
}
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-strips').forEach(initTabsStrips);Goal: Create a "Strips" tab set.
Visual behavior: Places as tall strips side by side: the chosen strip widens to show its scene and words, its label lying down, while the rest narrow with their labels standing on end.
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 strip is a tab button absolutely placed; picking one sets every strip's left and width, and the CSS transitions them on an overshooting curve, so the open strip grows as the others close up.
- The scene inside is an SVG drawn at the open size and centred, so a narrow strip shows a slice of its middle.
- Closed, the label is rotated -90deg up the strip; open, it lies flat. The panel sits over the open strip and fades up after it has opened.
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 strips change size 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 "Strips" tab set.
Visual behavior: Places as tall strips side by side: the chosen strip widens to show its scene and words, its label lying down, while the rest narrow with their labels standing on end.
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 strip is a tab button absolutely placed; picking one sets every strip's left and width, and the CSS transitions them on an overshooting curve, so the open strip grows as the others close up.
- The scene inside is an SVG drawn at the open size and centred, so a narrow strip shows a slice of its middle.
- Closed, the label is rotated -90deg up the strip; open, it lies flat. The panel sits over the open strip and fades up after it has opened.
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 strips change size 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
About this component
Places as tall strips side by side: the chosen strip widens to show its scene and words, its label lying down, while the rest narrow with their labels standing on end.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, spring
- 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 strips change size at once.
More tabs
See all2,481 photos. The latest is from this morning, at the harbour.
Playing Tides. Three albums saved for the train.
Home in 24 minutes by bike. Light rain after four.
Poster sizes, the ink order and a list of printers to call.
Heavyweight cotton, garment dyed and cut slightly boxy.