Torch
import { useId, useRef, useState } from 'react';
const TABS = [
{ label: 'Live', body: 'Four streams on air, one of them from your team.' },
{ label: 'Upcoming', body: 'The design review starts at 3 pm.' },
{ label: 'Replays', body: 'Last week’s talks, ready to watch.' },
];
const css = `
.tabs-torch { width: 260px; }
.tabs-torch__list { position: relative; display: grid; grid-template-columns: repeat(3, 1fr); padding: 4px; overflow: hidden; border-radius: 12px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#ebedf0, #232529); }
.tabs-torch__list::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(70px circle at var(--tabs-torch-x, 50%) 50%, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 16%, transparent), transparent 70%);
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.tabs-torch__list:hover::before { opacity: 1; }
.tabs-torch__line { position: absolute; left: 4px; bottom: 4px; width: calc((100% - 8px) / 3); height: 2px; transform: translateX(calc(var(--tabs-torch-i) * 100%)); transition: transform 0.4s cubic-bezier(0.3, 1.3, 0.5, 1); }
.tabs-torch__line::after { content: ''; position: absolute; left: 22%; right: 22%; top: 0; bottom: 0; border-radius: 1px; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 10px light-dark(#373a40, #f1f2f3); }
.tabs-torch__tab { position: relative; padding: 9px 0 11px; border: 0; border-radius: 8px; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; transition: color 0.2s; }
.tabs-torch__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-torch__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-torch__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; }
.tabs-torch__panel { padding: 14px 4px 0; min-height: calc(3.1em + 14px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
@media (prefers-reduced-motion: reduce) {
.tabs-torch__line { transition: none; }
}
`;
export function TorchTabs() {
const id = useId();
const [sel, setSel] = useState(0);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
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();
setSel(next);
tabs.current[next]?.focus();
};
return (
<>
<style>
{css}
</style>
<div className="tabs-torch" style={{ '--tabs-torch-i': sel } as React.CSSProperties}>
<div
className="tabs-torch__list"
role="tablist"
aria-label="Streams"
onKeyDown={onKeyDown}
onPointerMove={(e) => {
const r = e.currentTarget.getBoundingClientRect();
e.currentTarget.style.setProperty('--tabs-torch-x', `${Math.round(e.clientX - r.left)}px`);
}}
>
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-torch__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)}
>
{t.label}
</button>
))}
<span className="tabs-torch__line" aria-hidden="true" />
</div>
{TABS.map((t, i) => (
<div key={t.label} className="tabs-torch__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 initTabsTorch(el) on the mounted element instead.
<div class="tabs-torch" style="--tabs-torch-i:0">
<div class="tabs-torch__list" role="tablist" aria-label="Streams">
<button class="tabs-torch__tab" type="button" role="tab" id="tabs-torch-tab-0" aria-selected="true" aria-controls="tabs-torch-panel-0" tabindex="0">Live</button>
<button class="tabs-torch__tab" type="button" role="tab" id="tabs-torch-tab-1" aria-selected="false" aria-controls="tabs-torch-panel-1" tabindex="-1">Upcoming</button>
<button class="tabs-torch__tab" type="button" role="tab" id="tabs-torch-tab-2" aria-selected="false" aria-controls="tabs-torch-panel-2" tabindex="-1">Replays</button>
<span class="tabs-torch__line" aria-hidden="true"></span>
</div>
<div class="tabs-torch__panel" role="tabpanel" id="tabs-torch-panel-0" aria-labelledby="tabs-torch-tab-0">Four streams on air, one of them from your team.</div>
<div class="tabs-torch__panel" role="tabpanel" id="tabs-torch-panel-1" aria-labelledby="tabs-torch-tab-1" hidden="">The design review starts at 3 pm.</div>
<div class="tabs-torch__panel" role="tabpanel" id="tabs-torch-panel-2" aria-labelledby="tabs-torch-tab-2" hidden="">Last week’s talks, ready to watch.</div>
</div>.tabs-torch { width: 260px; }
.tabs-torch__list { position: relative; display: grid; grid-template-columns: repeat(3, 1fr); padding: 4px; overflow: hidden; border-radius: 12px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#ebedf0, #232529); }
.tabs-torch__list::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(70px circle at var(--tabs-torch-x, 50%) 50%, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 16%, transparent), transparent 70%);
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.tabs-torch__list:hover::before { opacity: 1; }
.tabs-torch__line { position: absolute; left: 4px; bottom: 4px; width: calc((100% - 8px) / 3); height: 2px; transform: translateX(calc(var(--tabs-torch-i) * 100%)); transition: transform 0.4s cubic-bezier(0.3, 1.3, 0.5, 1); }
.tabs-torch__line::after { content: ''; position: absolute; left: 22%; right: 22%; top: 0; bottom: 0; border-radius: 1px; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 10px light-dark(#373a40, #f1f2f3); }
.tabs-torch__tab { position: relative; padding: 9px 0 11px; border: 0; border-radius: 8px; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; transition: color 0.2s; }
.tabs-torch__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-torch__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-torch__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; }
.tabs-torch__panel { padding: 14px 4px 0; min-height: calc(3.1em + 14px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
@media (prefers-reduced-motion: reduce) {
.tabs-torch__line { transition: none; }
}function initTabsTorch(root) {
var list = root.querySelector('[role="tablist"]');
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;
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; });
root.style.setProperty('--tabs-torch-i', 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();
}
function onPointerMove(e) {
// The torch follows the pointer along the row.
var r = list.getBoundingClientRect();
list.style.setProperty('--tabs-torch-x', Math.round(e.clientX - r.left) + 'px');
}
list.addEventListener('click', onClick);
list.addEventListener('keydown', onKeyDown);
list.addEventListener('pointermove', onPointerMove);
return function () {
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
list.removeEventListener('pointermove', onPointerMove);
};
}
document.querySelectorAll('.tabs-torch').forEach(initTabsTorch);Goal: Create a "Torch" tab set.
Visual behavior: Stream tabs lit by a soft torch that follows your pointer along the row, above an underline that slides to 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.
- Pointer moves over the list write the x position to a custom property that centres a radial-gradient glow; the underline is positioned from the selected index.
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 underline jumps to the new tab.
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 "Torch" tab set.
Visual behavior: Stream tabs lit by a soft torch that follows your pointer along the row, above an underline that slides to 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.
- Pointer moves over the list write the x position to a custom property that centres a radial-gradient glow; the underline is positioned from the selected index.
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 underline jumps to the new tab.
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
Stream tabs lit by a soft torch that follows your pointer along the row, above an underline that slides to the active tab.
- Dependencies
- None
- Difficulty
- Beginner
- Triggers
- click, state change, mouse tracking, spotlight
- Techniques
- css variables, radial gradient, event position
- 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 underline jumps to the new tab.