Wheel
import { useId, useRef, useState } from 'react';
const TABS = [
{ label: 'Mon', body: 'Stand-up at 9:30, then a clear afternoon for deep work.' },
{ label: 'Tue', body: 'Design review at 11 and a client call at 3.' },
{ label: 'Wed', body: 'No meetings. Ship the onboarding flow.' },
{ label: 'Thu', body: 'Team lunch at 1 and a workshop until 5.' },
{ label: 'Fri', body: 'Demo day at 4, then the weekly write-up.' },
{ label: 'Sat', body: 'Nothing planned. Keep it that way.' },
{ label: 'Sun', body: 'Plan the week ahead over a slow breakfast.' },
];
const STEP = 25;
const R = 170;
const css = `
.tabs-wheel { width: 260px; }
.tabs-wheel__list { position: relative; height: 44px; perspective: 520px; overflow: hidden; }
.tabs-wheel__drum {
position: absolute;
left: 50%;
top: 0;
width: 0;
height: 44px;
transform-style: preserve-3d;
transform: translateZ(-${R}px) rotateY(calc(var(--tabs-wheel-i) * -${STEP}deg));
transition: transform 0.65s cubic-bezier(0.3, 1.25, 0.5, 1);
}
.tabs-wheel__tab {
position: absolute;
left: -30px;
top: 4px;
width: 60px;
height: 36px;
border: 0;
border-radius: 10px;
background: none;
color: light-dark(#50535b, #c6c8cd);
font: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
backface-visibility: hidden;
transition: color 0.3s, opacity 0.4s;
}
.tabs-wheel__tab:hover { color: light-dark(#373a40, #f1f2f3); }
.tabs-wheel__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-wheel__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; }
/* The front of the wheel is marked by a fixed dot; the days turn past it. */
.tabs-wheel__mark { display: block; width: 4px; height: 4px; margin: 2px auto 0; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); }
.tabs-wheel__panel { padding: 12px 6px 0; min-height: calc(3.1em + 12px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; text-align: center; }
@media (prefers-reduced-motion: reduce) {
.tabs-wheel__drum { transition: none; }
}
`;
export function WheelTabs() {
const id = useId();
const [sel, setSel] = useState(2);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const onKeyDown = (e: React.KeyboardEvent) => {
const n = TABS.length;
const next = { ArrowRight: Math.min(n - 1, sel + 1), ArrowLeft: Math.max(0, sel - 1), 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-wheel">
<div className="tabs-wheel__list" role="tablist" aria-label="Day" onKeyDown={onKeyDown}>
<div className="tabs-wheel__drum" style={{ '--tabs-wheel-i': sel } as React.CSSProperties}>
{TABS.map((t, i) => {
const d = Math.abs(i - sel);
return (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-wheel__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)}
// Days fade as they turn away; the ones round the back can't be clicked.
style={{ transform: `rotateY(${i * STEP}deg) translateZ(${R}px)`, opacity: [1, 0.7, 0.42, 0.18][d] ?? 0, pointerEvents: d > 3 ? 'none' : undefined }}
>
{t.label}
</button>
);
})}
</div>
</div>
<span className="tabs-wheel__mark" aria-hidden="true" />
{TABS.map((t, i) => (
<div key={t.label} className="tabs-wheel__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 initTabsWheel(el) on the mounted element instead.
<div class="tabs-wheel">
<div class="tabs-wheel__list" role="tablist" aria-label="Day">
<div class="tabs-wheel__drum" style="--tabs-wheel-i:2">
<button class="tabs-wheel__tab" type="button" role="tab" id="tabs-wheel-tab-0" aria-selected="false" aria-controls="tabs-wheel-panel-0" tabindex="-1" style="transform:rotateY(0deg) translateZ(170px);opacity:0.42">Mon</button>
<button class="tabs-wheel__tab" type="button" role="tab" id="tabs-wheel-tab-1" aria-selected="false" aria-controls="tabs-wheel-panel-1" tabindex="-1" style="transform:rotateY(25deg) translateZ(170px);opacity:0.7">Tue</button>
<button class="tabs-wheel__tab" type="button" role="tab" id="tabs-wheel-tab-2" aria-selected="true" aria-controls="tabs-wheel-panel-2" tabindex="0" style="transform:rotateY(50deg) translateZ(170px);opacity:1">Wed</button>
<button class="tabs-wheel__tab" type="button" role="tab" id="tabs-wheel-tab-3" aria-selected="false" aria-controls="tabs-wheel-panel-3" tabindex="-1" style="transform:rotateY(75deg) translateZ(170px);opacity:0.7">Thu</button>
<button class="tabs-wheel__tab" type="button" role="tab" id="tabs-wheel-tab-4" aria-selected="false" aria-controls="tabs-wheel-panel-4" tabindex="-1" style="transform:rotateY(100deg) translateZ(170px);opacity:0.42">Fri</button>
<button class="tabs-wheel__tab" type="button" role="tab" id="tabs-wheel-tab-5" aria-selected="false" aria-controls="tabs-wheel-panel-5" tabindex="-1" style="transform:rotateY(125deg) translateZ(170px);opacity:0.18">Sat</button>
<button class="tabs-wheel__tab" type="button" role="tab" id="tabs-wheel-tab-6" aria-selected="false" aria-controls="tabs-wheel-panel-6" tabindex="-1" style="transform:rotateY(150deg) translateZ(170px);opacity:0;pointer-events:none">Sun</button>
</div>
</div>
<span class="tabs-wheel__mark" aria-hidden="true"></span>
<div class="tabs-wheel__panel" role="tabpanel" id="tabs-wheel-panel-0" aria-labelledby="tabs-wheel-tab-0" hidden="">Stand-up at 9:30, then a clear afternoon for deep work.</div>
<div class="tabs-wheel__panel" role="tabpanel" id="tabs-wheel-panel-1" aria-labelledby="tabs-wheel-tab-1" hidden="">Design review at 11 and a client call at 3.</div>
<div class="tabs-wheel__panel" role="tabpanel" id="tabs-wheel-panel-2" aria-labelledby="tabs-wheel-tab-2">No meetings. Ship the onboarding flow.</div>
<div class="tabs-wheel__panel" role="tabpanel" id="tabs-wheel-panel-3" aria-labelledby="tabs-wheel-tab-3" hidden="">Team lunch at 1 and a workshop until 5.</div>
<div class="tabs-wheel__panel" role="tabpanel" id="tabs-wheel-panel-4" aria-labelledby="tabs-wheel-tab-4" hidden="">Demo day at 4, then the weekly write-up.</div>
<div class="tabs-wheel__panel" role="tabpanel" id="tabs-wheel-panel-5" aria-labelledby="tabs-wheel-tab-5" hidden="">Nothing planned. Keep it that way.</div>
<div class="tabs-wheel__panel" role="tabpanel" id="tabs-wheel-panel-6" aria-labelledby="tabs-wheel-tab-6" hidden="">Plan the week ahead over a slow breakfast.</div>
</div>.tabs-wheel { width: 260px; }
.tabs-wheel__list { position: relative; height: 44px; perspective: 520px; overflow: hidden; }
.tabs-wheel__drum {
position: absolute;
left: 50%;
top: 0;
width: 0;
height: 44px;
transform-style: preserve-3d;
transform: translateZ(-170px) rotateY(calc(var(--tabs-wheel-i) * -25deg));
transition: transform 0.65s cubic-bezier(0.3, 1.25, 0.5, 1);
}
.tabs-wheel__tab {
position: absolute;
left: -30px;
top: 4px;
width: 60px;
height: 36px;
border: 0;
border-radius: 10px;
background: none;
color: light-dark(#50535b, #c6c8cd);
font: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
backface-visibility: hidden;
transition: color 0.3s, opacity 0.4s;
}
.tabs-wheel__tab:hover { color: light-dark(#373a40, #f1f2f3); }
.tabs-wheel__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-wheel__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; }
/* The front of the wheel is marked by a fixed dot; the days turn past it. */
.tabs-wheel__mark { display: block; width: 4px; height: 4px; margin: 2px auto 0; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); }
.tabs-wheel__panel { padding: 12px 6px 0; min-height: calc(3.1em + 12px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; text-align: center; }
@media (prefers-reduced-motion: reduce) {
.tabs-wheel__drum { transition: none; }
}function initTabsWheel(root) {
var list = root.querySelector('[role="tablist"]');
var drum = root.querySelector('.tabs-wheel__drum');
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) {
if (i === sel) return;
sel = i;
// Turning the drum by one step per day brings the chosen one to the front.
drum.style.setProperty('--tabs-wheel-i', i);
tabs.forEach(function (tab, k) {
var d = Math.abs(k - i);
var fade = [1, 0.7, 0.42, 0.18][d];
tab.setAttribute('aria-selected', String(k === i));
tab.tabIndex = k === i ? 0 : -1;
// Days fade as they turn away; the ones round the back can't be clicked.
tab.style.opacity = fade === undefined ? 0 : fade;
tab.style.pointerEvents = d > 3 ? 'none' : '';
});
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: Math.min(n - 1, sel + 1), ArrowLeft: Math.max(0, sel - 1), 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-wheel').forEach(initTabsWheel);Goal: Create a "Wheel" tab set.
Visual behavior: Days of the week on a turning wheel: pick one and the wheel rolls it to the front while the rest turn away into 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 (stopping at either end), Home and End move between tabs and select as they go.
- Each day is placed round a vertical drum with rotateY(i × 25deg) translateZ(170px) in a preserve-3d group, and the drum turns back by the selected index, so the chosen day faces the front.
- Days fade with their distance from the front, and the ones round the back stop taking clicks.
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 wheel turns 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 "Wheel" tab set.
Visual behavior: Days of the week on a turning wheel: pick one and the wheel rolls it to the front while the rest turn away into 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 (stopping at either end), Home and End move between tabs and select as they go.
- Each day is placed round a vertical drum with rotateY(i × 25deg) translateZ(170px) in a preserve-3d group, and the drum turns back by the selected index, so the chosen day faces the front.
- Days fade with their distance from the front, and the ones round the back stop taking clicks.
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 wheel turns 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
Days of the week on a turning wheel: pick one and the wheel rolls it to the front while the rest turn away into depth.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, 3d
- Techniques
- preserve 3d, 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 wheel turns at once.