Selector
import { useId, useRef, useState } from 'react';
const ITEMS = [
{ label: 'Eco', body: 'Lower power, quieter fans, longer battery.' },
{ label: 'Normal', body: 'Balanced for everyday work.' },
{ label: 'Boost', body: 'Full clock speed while plugged in.' },
{ label: 'Silent', body: 'Fans off; the system throttles to keep cool.' },
];
const ANGLES = [-60, -20, 20, 60];
const css = `
.tabs-selector { display: grid; grid-template-columns: 132px 1fr; align-items: center; gap: 14px; width: 280px; }
.tabs-selector__list { position: relative; grid-row: 1; grid-column: 1; width: 132px; height: 120px; }
.tabs-selector__tab { position: relative; padding: 0; border: 0; background: none; font: inherit; cursor: pointer; }
.tabs-selector__tab:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; border-radius: 8px; }
/* Labels sit round the left half of the dial, one per detent. */
.tabs-selector__tab { position: absolute; left: calc(var(--x) * 1px); top: calc(var(--y) * 1px); padding: 2px 4px; color: light-dark(#5f626a, #a7abb2); font-size: 11.5px; white-space: nowrap; transform: translate(-100%, -50%); transition: color 0.2s; }
.tabs-selector__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); font-weight: 600; }
.tabs-selector__knob { position: absolute; right: 0; top: 50%; width: 56px; height: 56px; margin-top: -28px; border-radius: 50%; background: radial-gradient(circle at 40% 35%, light-dark(#cbced3, #393c41), light-dark(#ebedf0, #232529)); box-shadow: 0 0 0 4px light-dark(#f3f4f7, #1a1c1f), 0 0 0 5px light-dark(#dbdde1, #2e3035), 0 8px 16px -8px light-dark(rgba(0, 0, 0, 0.21), rgba(0, 0, 0, 0.7)); pointer-events: none; transform: rotate(calc(-90deg - var(--a) * 1deg)); transition: transform 0.45s cubic-bezier(0.3, 1.6, 0.5, 1); }
.tabs-selector__knob::before { content: ''; position: absolute; left: 50%; top: 5px; width: 3px; height: 14px; margin-left: -1.5px; border-radius: 2px; background: light-dark(#373a40, #f1f2f3); }
.tabs-selector__knob::after { content: ''; position: absolute; inset: 12px; border-radius: 50%; background: repeating-conic-gradient(light-dark(#dbdde1, #2e3035) 0 6deg, light-dark(#ebedf0, #232529) 6deg 12deg); opacity: 0.6; }
.tabs-selector__panel { grid-row: 1; grid-column: 2; color: light-dark(#50535b, #c6c8cd); font-size: 12.5px; line-height: 1.5; }
.tabs-selector__panel b { display: block; color: light-dark(#373a40, #f1f2f3); font-size: 14px; }
@media (prefers-reduced-motion: reduce) {
.tabs-selector__knob { transition: none; }
}
`;
export function SelectorTabs() {
const id = useId();
const [state, setState] = useState({ sel: 1, prev: -1, flip: 0 });
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const { sel, prev, flip } = state;
const select = (i: number) => {
if (i !== sel) setState((s) => ({ sel: i, prev: s.sel, flip: s.flip + 1 }));
};
const onKeyDown = (e: React.KeyboardEvent) => {
const n = ITEMS.length;
const next = ({ ArrowDown: (sel + 1) % n, ArrowUp: (sel + n - 1) % n, ArrowRight: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, Home: 0, End: n - 1 } as Record<string, number>)[e.key];
if (next === undefined) return;
e.preventDefault();
select(next);
tabs.current[next]?.focus();
};
return (
<>
<style>
{css}
</style>
<div
className="tabs-selector"
data-armed={flip ? '' : undefined}
data-flip={flip ? (flip % 2 ? 'a' : 'b') : undefined}
style={{ '--i': sel, '--p': prev < 0 ? sel : prev, '--a': ANGLES[sel] } as React.CSSProperties}
>
<div className="tabs-selector__list" role="tablist" aria-label="Mode" aria-orientation="vertical" onKeyDown={onKeyDown}>
{ITEMS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-selector__tab"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={sel === i ? 0 : -1}
onClick={() => select(i)}
style={{ '--x': 104 - 46 * Math.cos(((ANGLES[i] ?? 0) * Math.PI) / 180), '--y': 60 + 46 * Math.sin(((ANGLES[i] ?? 0) * Math.PI) / 180) } as React.CSSProperties}
>
{t.label}
</button>
))}
<span className="tabs-selector__knob" aria-hidden="true" />
</div>
{ITEMS.map((t, i) => (
<div key={t.label} className="tabs-selector__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
<b>{t.label}</b>
{t.body}
</div>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsSelector(el) on the mounted element instead.
<div class="tabs-selector" style="--i:1;--p:1;--a:-20">
<div class="tabs-selector__list" role="tablist" aria-label="Mode" aria-orientation="vertical">
<button class="tabs-selector__tab" type="button" role="tab" id="tabs-selector-tab-0" aria-selected="false" aria-controls="tabs-selector-panel-0" tabindex="-1" style="--x:81;--y:20.162831425915826">Eco</button>
<button class="tabs-selector__tab" type="button" role="tab" id="tabs-selector-tab-1" aria-selected="true" aria-controls="tabs-selector-panel-1" tabindex="0" style="--x:60.77413944384821;--y:44.26707340701924">Normal</button>
<button class="tabs-selector__tab" type="button" role="tab" id="tabs-selector-tab-2" aria-selected="false" aria-controls="tabs-selector-panel-2" tabindex="-1" style="--x:60.77413944384821;--y:75.73292659298076">Boost</button>
<button class="tabs-selector__tab" type="button" role="tab" id="tabs-selector-tab-3" aria-selected="false" aria-controls="tabs-selector-panel-3" tabindex="-1" style="--x:81;--y:99.83716857408417">Silent</button>
<span class="tabs-selector__knob" aria-hidden="true"></span>
</div>
<div class="tabs-selector__panel" role="tabpanel" id="tabs-selector-panel-0" aria-labelledby="tabs-selector-tab-0" hidden=""><b>Eco</b>Lower power, quieter fans, longer battery.</div>
<div class="tabs-selector__panel" role="tabpanel" id="tabs-selector-panel-1" aria-labelledby="tabs-selector-tab-1"><b>Normal</b>Balanced for everyday work.</div>
<div class="tabs-selector__panel" role="tabpanel" id="tabs-selector-panel-2" aria-labelledby="tabs-selector-tab-2" hidden=""><b>Boost</b>Full clock speed while plugged in.</div>
<div class="tabs-selector__panel" role="tabpanel" id="tabs-selector-panel-3" aria-labelledby="tabs-selector-tab-3" hidden=""><b>Silent</b>Fans off; the system throttles to keep cool.</div>
</div>.tabs-selector { display: grid; grid-template-columns: 132px 1fr; align-items: center; gap: 14px; width: 280px; }
.tabs-selector__list { position: relative; grid-row: 1; grid-column: 1; width: 132px; height: 120px; }
.tabs-selector__tab { position: relative; padding: 0; border: 0; background: none; font: inherit; cursor: pointer; }
.tabs-selector__tab:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; border-radius: 8px; }
/* Labels sit round the left half of the dial, one per detent. */
.tabs-selector__tab { position: absolute; left: calc(var(--x) * 1px); top: calc(var(--y) * 1px); padding: 2px 4px; color: light-dark(#5f626a, #a7abb2); font-size: 11.5px; white-space: nowrap; transform: translate(-100%, -50%); transition: color 0.2s; }
.tabs-selector__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); font-weight: 600; }
.tabs-selector__knob { position: absolute; right: 0; top: 50%; width: 56px; height: 56px; margin-top: -28px; border-radius: 50%; background: radial-gradient(circle at 40% 35%, light-dark(#cbced3, #393c41), light-dark(#ebedf0, #232529)); box-shadow: 0 0 0 4px light-dark(#f3f4f7, #1a1c1f), 0 0 0 5px light-dark(#dbdde1, #2e3035), 0 8px 16px -8px light-dark(rgba(0, 0, 0, 0.21), rgba(0, 0, 0, 0.7)); pointer-events: none; transform: rotate(calc(-90deg - var(--a) * 1deg)); transition: transform 0.45s cubic-bezier(0.3, 1.6, 0.5, 1); }
.tabs-selector__knob::before { content: ''; position: absolute; left: 50%; top: 5px; width: 3px; height: 14px; margin-left: -1.5px; border-radius: 2px; background: light-dark(#373a40, #f1f2f3); }
.tabs-selector__knob::after { content: ''; position: absolute; inset: 12px; border-radius: 50%; background: repeating-conic-gradient(light-dark(#dbdde1, #2e3035) 0 6deg, light-dark(#ebedf0, #232529) 6deg 12deg); opacity: 0.6; }
.tabs-selector__panel { grid-row: 1; grid-column: 2; color: light-dark(#50535b, #c6c8cd); font-size: 12.5px; line-height: 1.5; }
.tabs-selector__panel b { display: block; color: light-dark(#373a40, #f1f2f3); font-size: 14px; }
@media (prefers-reduced-motion: reduce) {
.tabs-selector__knob { transition: none; }
}function initTabsSelector(root) {
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = root.querySelectorAll('[role="tabpanel"]');
var ANGLES = [-60, -20, 20, 60];
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
var flip = 0;
function select(i) {
if (i === sel) return;
var prev = sel;
sel = i;
// Armed on the first change, so nothing animates on load; each change flips data-flip
// between a and b, so the keyframes restart every time.
flip += 1;
root.setAttribute('data-armed', '');
root.setAttribute('data-flip', flip % 2 ? 'a' : 'b');
root.style.setProperty('--i', i);
root.style.setProperty('--p', prev);
root.style.setProperty('--a', ANGLES[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));
}
function onKeyDown(e) {
var n = tabs.length;
var next = { ArrowDown: (sel + 1) % n, ArrowUp: (sel + n - 1) % n, 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-selector').forEach(initTabsSelector);Goal: Create a "Selector" tab set.
Visual behavior: Mode tabs on a rotary selector: the knob turns to point at the chosen mode with a firm detent, and the labels sit round the dial at each stop.
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.
- The tablist is vertical (aria-orientation="vertical"); Up and Down, as well as Left and Right, move round the dial.
- Each tab is placed round the knob from its angle with --x and --y; the selected angle is a CSS custom property (--a) and the pointer turns to it on an overshooting transition, like a detent.
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 knob turns instantly.
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 "Selector" tab set.
Visual behavior: Mode tabs on a rotary selector: the knob turns to point at the chosen mode with a firm detent, and the labels sit round the dial at each stop.
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.
- The tablist is vertical (aria-orientation="vertical"); Up and Down, as well as Left and Right, move round the dial.
- Each tab is placed round the knob from its angle with --x and --y; the selected angle is a CSS custom property (--a) and the pointer turns to it on an overshooting transition, like a detent.
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 knob turns instantly.
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
Mode tabs on a rotary selector: the knob turns to point at the chosen mode with a firm detent, and the labels sit round the dial at each stop.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, spring
- Techniques
- css variables, css transition, radial gradient
- 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 knob turns instantly.