Arc
import { useEffect, useId, useRef, useState } from 'react';
const SOURCES = [
{ label: 'Grid', body: 'Drawing 1.2 kW from the grid, on the off-peak rate until seven.' },
{ label: 'Solar', body: 'Panels making 3.4 kW. The battery is full by eleven on days like this.' },
{ label: 'Wind', body: 'The turbine is idling: the wind dropped below 3 m/s at noon.' },
{ label: 'Hydro', body: 'The stream wheel runs day and night, a steady 0.6 kW.' },
];
const W = 240;
const COL = W / SOURCES.length;
const NODE_Y = 12;
const nodeX = (i: number) => i * COL + COL / 2;
const css = `
.tabs-arc { width: ${W}px; }
.tabs-arc__top { position: relative; }
.tabs-arc__list { display: grid; grid-template-columns: repeat(${SOURCES.length}, 1fr); padding-top: 8px; }
.tabs-arc__tab { display: grid; justify-items: center; gap: 8px; padding: 0 0 6px; border: 0; background: none; font: inherit; font-size: 12.5px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.25s; }
.tabs-arc__tab:hover, .tabs-arc__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-arc__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; border-radius: 6px; }
.tabs-arc__node { width: 8px; height: 8px; box-sizing: border-box; border: 1.5px solid light-dark(#cbced3, #393c41); border-radius: 50%; transition: background-color 0.2s, border-color 0.2s; }
.tabs-arc__tab[aria-selected='true'] .tabs-arc__node { border-color: light-dark(#373a40, #f1f2f3); background: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 6px light-dark(#50535b, #c6c8cd); animation: tabs-arc-flash 0.5s ease-out; }
@keyframes tabs-arc-flash {
from { transform: scale(2.2); box-shadow: 0 0 14px light-dark(#373a40, #f1f2f3); }
}
.tabs-arc__bolt { position: absolute; left: 0; top: 0; width: ${W}px; height: 40px; overflow: visible; pointer-events: none; }
.tabs-arc__bolt path { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 1.4; stroke-linejoin: bevel; filter: drop-shadow(0 0 3px light-dark(#50535b, #c6c8cd)); }
.tabs-arc__panel { padding: 12px 0 0; min-height: calc(3.1em + 12px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
@media (prefers-reduced-motion: reduce) {
.tabs-arc__tab[aria-selected='true'] .tabs-arc__node { animation: none; }
}
`;
// A jagged bolt along an arc from x0 to x1: points on a curve, each kicked sideways at random,
// most in the middle and least at the ends, with a short fork off one of them.
function bolt(x0: number, x1: number) {
const dx = x1 - x0;
const h = 16 + Math.abs(dx) * 0.12;
const n = 10 + Math.round(Math.abs(dx) / 12);
let main = '';
let fork = '';
const at = 2 + Math.floor(Math.random() * (n - 4));
for (let k = 0; k <= n; k++) {
const t = k / n;
const kick = (Math.random() - 0.5) * 9 * Math.sin(Math.PI * t);
const x = x0 + dx * t + kick * 0.4;
const y = NODE_Y - 4 * h * t * (1 - t) + kick;
main += `${k ? 'L' : 'M'}${x.toFixed(1)} ${y.toFixed(1)}`;
if (k === at)
fork = `M${x.toFixed(1)} ${y.toFixed(1)}l${((Math.random() - 0.5) * 12).toFixed(1)} ${(-6 - Math.random() * 6).toFixed(1)}l${((Math.random() - 0.5) * 8).toFixed(1)} -4`;
}
return main + fork;
}
export function ArcTabs() {
const id = useId();
const [sel, setSel] = useState(0);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const path = useRef<SVGPathElement>(null);
const prev = useRef(0);
// The current jumps from the old source to the new one: the bolt is redrawn every few frames
// for a third of a second, flickering, then the new node lights.
useEffect(() => {
const from = prev.current;
prev.current = sel;
if (from === sel || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const el = path.current!;
const t0 = performance.now();
let raf = 0;
let last = 0;
const step = (now: number) => {
if (now - t0 > 360) {
el.setAttribute('d', '');
return;
}
if (now - last > 40) {
last = now;
el.setAttribute('d', bolt(nodeX(from), nodeX(sel)));
el.style.opacity = String(0.55 + Math.random() * 0.45);
}
raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => {
cancelAnimationFrame(raf);
el.setAttribute('d', '');
};
}, [sel]);
const n = SOURCES.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-arc">
<div className="tabs-arc__top">
<div className="tabs-arc__list" role="tablist" aria-label="Power" onKeyDown={onKeyDown}>
{SOURCES.map((s, i) => (
<button
key={s.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-arc__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)}
>
<span className="tabs-arc__node" aria-hidden="true" />
{s.label}
</button>
))}
</div>
<svg className="tabs-arc__bolt" aria-hidden="true">
<path ref={path} />
</svg>
</div>
{SOURCES.map((s, i) => (
<div key={s.label} className="tabs-arc__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
{s.body}
</div>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsArc(el) on the mounted element instead.
<div class="tabs-arc">
<div class="tabs-arc__top">
<div class="tabs-arc__list" role="tablist" aria-label="Power">
<button class="tabs-arc__tab" type="button" role="tab" id="tabs-arc-tab-0" aria-selected="true" aria-controls="tabs-arc-panel-0" tabindex="0">
<span class="tabs-arc__node" aria-hidden="true"></span>
Grid
</button>
<button class="tabs-arc__tab" type="button" role="tab" id="tabs-arc-tab-1" aria-selected="false" aria-controls="tabs-arc-panel-1" tabindex="-1">
<span class="tabs-arc__node" aria-hidden="true"></span>
Solar
</button>
<button class="tabs-arc__tab" type="button" role="tab" id="tabs-arc-tab-2" aria-selected="false" aria-controls="tabs-arc-panel-2" tabindex="-1">
<span class="tabs-arc__node" aria-hidden="true"></span>
Wind
</button>
<button class="tabs-arc__tab" type="button" role="tab" id="tabs-arc-tab-3" aria-selected="false" aria-controls="tabs-arc-panel-3" tabindex="-1">
<span class="tabs-arc__node" aria-hidden="true"></span>
Hydro
</button>
</div>
<svg class="tabs-arc__bolt" aria-hidden="true">
<path></path>
</svg>
</div>
<div class="tabs-arc__panel" role="tabpanel" id="tabs-arc-panel-0" aria-labelledby="tabs-arc-tab-0">Drawing 1.2 kW from the grid, on the off-peak rate until seven.</div>
<div class="tabs-arc__panel" role="tabpanel" id="tabs-arc-panel-1" aria-labelledby="tabs-arc-tab-1" hidden="">Panels making 3.4 kW. The battery is full by eleven on days like this.</div>
<div class="tabs-arc__panel" role="tabpanel" id="tabs-arc-panel-2" aria-labelledby="tabs-arc-tab-2" hidden="">The turbine is idling: the wind dropped below 3 m/s at noon.</div>
<div class="tabs-arc__panel" role="tabpanel" id="tabs-arc-panel-3" aria-labelledby="tabs-arc-tab-3" hidden="">The stream wheel runs day and night, a steady 0.6 kW.</div>
</div>.tabs-arc { width: 240px; }
.tabs-arc__top { position: relative; }
.tabs-arc__list { display: grid; grid-template-columns: repeat(4, 1fr); padding-top: 8px; }
.tabs-arc__tab { display: grid; justify-items: center; gap: 8px; padding: 0 0 6px; border: 0; background: none; font: inherit; font-size: 12.5px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.25s; }
.tabs-arc__tab:hover, .tabs-arc__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-arc__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; border-radius: 6px; }
.tabs-arc__node { width: 8px; height: 8px; box-sizing: border-box; border: 1.5px solid light-dark(#cbced3, #393c41); border-radius: 50%; transition: background-color 0.2s, border-color 0.2s; }
.tabs-arc__tab[aria-selected='true'] .tabs-arc__node { border-color: light-dark(#373a40, #f1f2f3); background: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 6px light-dark(#50535b, #c6c8cd); animation: tabs-arc-flash 0.5s ease-out; }
@keyframes tabs-arc-flash {
from { transform: scale(2.2); box-shadow: 0 0 14px light-dark(#373a40, #f1f2f3); }
}
.tabs-arc__bolt { position: absolute; left: 0; top: 0; width: 240px; height: 40px; overflow: visible; pointer-events: none; }
.tabs-arc__bolt path { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 1.4; stroke-linejoin: bevel; filter: drop-shadow(0 0 3px light-dark(#50535b, #c6c8cd)); }
.tabs-arc__panel { padding: 12px 0 0; min-height: calc(3.1em + 12px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
@media (prefers-reduced-motion: reduce) {
.tabs-arc__tab[aria-selected='true'] .tabs-arc__node { animation: none; }
}function initTabsArc(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 COL = 240 / n;
var NODE_Y = 12;
var path = root.querySelector('.tabs-arc__bolt path');
var raf = 0;
function nodeX(i) { return i * COL + COL / 2; }
// A jagged arc from one node to the other, bowed upwards, with a short fork off one of its kinks.
function bolt(x0, x1) {
var dx = x1 - x0;
var h = 16 + Math.abs(dx) * 0.12;
var steps = 10 + Math.round(Math.abs(dx) / 12);
var main = '';
var fork = '';
var at = 2 + Math.floor(Math.random() * (steps - 4));
for (var k = 0; k <= steps; k++) {
var t = k / steps;
var kick = (Math.random() - 0.5) * 9 * Math.sin(Math.PI * t);
var x = x0 + dx * t + kick * 0.4;
var y = NODE_Y - 4 * h * t * (1 - t) + kick;
main += (k ? 'L' : 'M') + x.toFixed(1) + ' ' + y.toFixed(1);
if (k === at) fork = 'M' + x.toFixed(1) + ' ' + y.toFixed(1) + 'l' + ((Math.random() - 0.5) * 12).toFixed(1) + ' ' + (-6 - Math.random() * 6).toFixed(1) + 'l' + ((Math.random() - 0.5) * 8).toFixed(1) + ' -4';
}
return main + fork;
}
// The current jumps from the old source to the new one: the bolt is redrawn every few frames
// for a third of a second, flickering, then the new node lights.
function strike(from, to) {
cancelAnimationFrame(raf);
path.setAttribute('d', '');
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
var t0 = performance.now();
var last = 0;
function step(now) {
if (now - t0 > 360) return path.setAttribute('d', '');
if (now - last > 40) {
last = now;
path.setAttribute('d', bolt(nodeX(from), nodeX(to)));
path.style.opacity = String(0.55 + Math.random() * 0.45);
}
raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
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; });
strike(prev, 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 () {
cancelAnimationFrame(raf);
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.tabs-arc').forEach(initTabsArc);Goal: Create a "Arc" tab set.
Visual behavior: Power sources as tabs: an electric arc jumps from the old source to the new one, crackling and flickering for a moment before the new node lights.
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 arc is one SVG path redrawn every 40ms for about a third of a second: a bowed line between the two nodes with random kicks at each step and a short fork off one kink, its opacity flickering.
- The node under the chosen tab lights with a glow transition as the arc fades.
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 there is no arc and the node simply lights.
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 "Arc" tab set.
Visual behavior: Power sources as tabs: an electric arc jumps from the old source to the new one, crackling and flickering for a moment before the new node lights.
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 arc is one SVG path redrawn every 40ms for about a third of a second: a bowed line between the two nodes with random kicks at each step and a short fork off one kink, its opacity flickering.
- The node under the chosen tab lights with a glow transition as the arc fades.
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 there is no arc and the node simply lights.
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
Power sources as tabs: an electric arc jumps from the old source to the new one, crackling and flickering for a moment before the new node lights.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, glow
- Techniques
- raf, svg, filter
- 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 there is no arc and the node simply lights.