Patch
import { useEffect, useId, useRef, useState } from 'react';
const LINES = [
{ label: 'Sales', body: 'Three calls waiting. Average wait is under a minute.' },
{ label: 'Support', body: 'Busy this morning: the new release went out at nine.' },
{ label: 'Billing', body: 'Quiet. Invoices for October go out on Friday.' },
{ label: 'Press', body: 'One request, from a design magazine, about the rebrand.' },
];
const W = 240;
const COL = W / LINES.length;
const JACK_Y = 36;
const SOCKET = { x: 120, y: 112 };
const N = 15;
const SEG = 9.2;
const G = 0.3;
type Pt = { x: number; y: number; px: number; py: number };
const jack = (i: number) => ({ x: i * COL + COL / 2, y: JACK_Y });
// A cord of N links (verlet): each point keeps its own momentum, gravity pulls it down, and a few
// passes of length constraints keep the links a fixed length. Both ends are held: the switchboard
// socket and the plug.
function simulate(p: Pt[], end: { x: number; y: number }) {
for (let i = 1; i < N; i++) {
const q = p[i];
const vx = (q.x - q.px) * 0.97;
const vy = (q.y - q.py) * 0.97;
q.px = q.x;
q.py = q.y;
q.x += vx;
q.y += vy + G;
}
for (let k = 0; k < 10; k++) {
Object.assign(p[0], SOCKET);
p[N].x = end.x;
p[N].y = end.y;
for (let i = 0; i < N; i++) {
const a = p[i];
const b = p[i + 1];
const dx = b.x - a.x;
const dy = b.y - a.y;
const d = Math.hypot(dx, dy) || 1;
const f = (d - SEG) / d / 2;
if (i > 0) {
a.x += dx * f;
a.y += dy * f;
}
if (i < N - 1) {
b.x -= dx * f;
b.y -= dy * f;
}
}
}
}
function cord(end: { x: number; y: number }) {
const p: Pt[] = Array.from({ length: N + 1 }, (_, i) => {
const x = SOCKET.x + ((end.x - SOCKET.x) * i) / N;
const y = SOCKET.y + ((end.y - SOCKET.y) * i) / N;
return { x, y, px: x, py: y };
});
for (let k = 0; k < 400; k++) simulate(p, end);
return p;
}
function path(p: Pt[]) {
let d = `M${p[0].x.toFixed(1)} ${p[0].y.toFixed(1)}`;
for (let i = 1; i < N; i++) d += `Q${p[i].x.toFixed(1)} ${p[i].y.toFixed(1)} ${((p[i].x + p[i + 1].x) / 2).toFixed(1)} ${((p[i].y + p[i + 1].y) / 2).toFixed(1)}`;
return `${d}L${p[N].x.toFixed(1)} ${p[N].y.toFixed(1)}`;
}
// The cord at rest in the first jack, worked out the same way on the server and in the browser.
const REST = cord(jack(0));
const css = `
.tabs-patch { width: ${W}px; }
.tabs-patch__board { position: relative; height: 132px; border-radius: 12px; background: light-dark(#ebedf0, #232529); }
.tabs-patch__wire { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
.tabs-patch__cord { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 2.5; stroke-linecap: round; }
.tabs-patch__plug { stroke: light-dark(#373a40, #f1f2f3); stroke-width: 6; stroke-linecap: round; }
.tabs-patch__socket { fill: light-dark(#cbced3, #393c41); }
.tabs-patch__list { display: grid; grid-template-columns: repeat(${LINES.length}, 1fr); padding-top: 8px; }
.tabs-patch__tab { display: grid; justify-items: center; gap: 5px; padding: 0; border: 0; background: none; font: inherit; font-size: 11.5px; line-height: 15px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.25s; }
.tabs-patch__tab:hover, .tabs-patch__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-patch__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; border-radius: 6px; }
.tabs-patch__jack { width: 16px; height: 16px; box-sizing: border-box; border: 3px solid light-dark(#6d7179, #7d8087); border-radius: 50%; background: light-dark(#fbfcfd, #121417); }
.tabs-patch__tab[aria-selected='true'] .tabs-patch__jack { border-color: light-dark(#42454c, #e5e6e9); }
.tabs-patch__panel { padding: 12px 0 0; min-height: calc(3.1em + 12px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
`;
export function PatchTabs() {
const id = useId();
const [sel, setSel] = useState(0);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const wire = useRef<SVGPathElement>(null);
const plug = useRef<SVGLineElement>(null);
const st = useRef({ p: REST.map((q) => ({ ...q })), at: 0, raf: 0 });
const draw = (p: Pt[]) => {
wire.current!.setAttribute('d', path(p));
const a = p[N];
const b = p[N - 2];
const d = Math.hypot(a.x - b.x, a.y - b.y) || 1;
plug.current!.setAttribute('x1', String(a.x));
plug.current!.setAttribute('y1', String(a.y));
plug.current!.setAttribute('x2', String(a.x + ((b.x - a.x) / d) * 9));
plug.current!.setAttribute('y2', String(a.y + ((b.y - a.y) / d) * 9));
};
// Moving the call: the plug lifts out, arcs over to the new jack and pushes in, and the cord
// swings after it until it hangs still again.
useEffect(() => {
const s = st.current;
if (s.at === sel) return;
const from = jack(s.at);
const to = jack(sel);
s.at = sel;
cancelAnimationFrame(s.raf);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
s.p = cord(to);
return draw(s.p);
}
const t0 = performance.now();
let still = 0;
const step = (now: number) => {
const k = Math.min(1, (now - t0) / 600);
const e = k < 0.5 ? 2 * k * k : 1 - (-2 * k + 2) ** 2 / 2;
const end = { x: from.x + (to.x - from.x) * e, y: JACK_Y - Math.sin(k * Math.PI) * 18 };
const before = s.p.reduce((sum, q) => sum + q.x + q.y, 0);
simulate(s.p, end);
draw(s.p);
const moved = Math.abs(s.p.reduce((sum, q) => sum + q.x + q.y, 0) - before);
still = k === 1 && moved < 0.05 ? still + 1 : 0;
if (still < 10 && now - t0 < 4000) s.raf = requestAnimationFrame(step);
};
s.raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(s.raf);
}, [sel]);
const n = LINES.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();
};
const end = REST[N];
const back = REST[N - 2];
const len = Math.hypot(end.x - back.x, end.y - back.y) || 1;
return (
<>
<style>
{css}
</style>
<div className="tabs-patch">
<div className="tabs-patch__board">
<div className="tabs-patch__list" role="tablist" aria-label="Lines" onKeyDown={onKeyDown}>
{LINES.map((l, i) => (
<button
key={l.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-patch__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)}
>
{l.label}
<span className="tabs-patch__jack" aria-hidden="true" />
</button>
))}
</div>
<svg className="tabs-patch__wire" viewBox={`0 0 ${W} 132`} aria-hidden="true">
<rect className="tabs-patch__socket" x={SOCKET.x - 10} y={SOCKET.y - 2} width={20} height={14} rx={3} />
<path className="tabs-patch__cord" ref={wire} d={path(REST)} />
<line
className="tabs-patch__plug"
ref={plug}
x1={end.x.toFixed(1)}
y1={end.y.toFixed(1)}
x2={(end.x + ((back.x - end.x) / len) * 9).toFixed(1)}
y2={(end.y + ((back.y - end.y) / len) * 9).toFixed(1)}
/>
</svg>
</div>
{LINES.map((l, i) => (
<div key={l.label} className="tabs-patch__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
{l.body}
</div>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsPatch(el) on the mounted element instead.
<div class="tabs-patch">
<div class="tabs-patch__board">
<div class="tabs-patch__list" role="tablist" aria-label="Lines">
<button class="tabs-patch__tab" type="button" role="tab" id="tabs-patch-tab-0" aria-selected="true" aria-controls="tabs-patch-panel-0" tabindex="0">
Sales
<span class="tabs-patch__jack" aria-hidden="true"></span>
</button>
<button class="tabs-patch__tab" type="button" role="tab" id="tabs-patch-tab-1" aria-selected="false" aria-controls="tabs-patch-panel-1" tabindex="-1">
Support
<span class="tabs-patch__jack" aria-hidden="true"></span>
</button>
<button class="tabs-patch__tab" type="button" role="tab" id="tabs-patch-tab-2" aria-selected="false" aria-controls="tabs-patch-panel-2" tabindex="-1">
Billing
<span class="tabs-patch__jack" aria-hidden="true"></span>
</button>
<button class="tabs-patch__tab" type="button" role="tab" id="tabs-patch-tab-3" aria-selected="false" aria-controls="tabs-patch-panel-3" tabindex="-1">
Press
<span class="tabs-patch__jack" aria-hidden="true"></span>
</button>
</div>
<svg class="tabs-patch__wire" viewBox="0 0 240 132" aria-hidden="true">
<rect class="tabs-patch__socket" x="110" y="110" width="20" height="14" rx="3"></rect>
<path class="tabs-patch__cord" d="M120.0 112.0Q112.1 117.1 107.8 118.8Q103.4 120.5 98.8 121.0Q94.2 121.5 89.6 120.7Q85.0 119.9 80.8 118.0Q76.5 116.1 72.7 113.4Q68.9 110.6 65.6 107.3Q62.3 104.0 59.4 100.3Q56.5 96.6 54.0 92.6Q51.5 88.6 49.2 84.5Q46.9 80.3 44.9 76.0Q42.9 71.8 41.1 67.4Q39.2 63.0 37.6 58.5Q35.9 54.1 34.4 49.6Q32.9 45.1 31.4 40.5L30.0 36.0"></path>
<line class="tabs-patch__plug" x1="30.0" y1="36.0" x2="32.8" y2="44.6"></line>
</svg>
</div>
<div class="tabs-patch__panel" role="tabpanel" id="tabs-patch-panel-0" aria-labelledby="tabs-patch-tab-0">Three calls waiting. Average wait is under a minute.</div>
<div class="tabs-patch__panel" role="tabpanel" id="tabs-patch-panel-1" aria-labelledby="tabs-patch-tab-1" hidden="">Busy this morning: the new release went out at nine.</div>
<div class="tabs-patch__panel" role="tabpanel" id="tabs-patch-panel-2" aria-labelledby="tabs-patch-tab-2" hidden="">Quiet. Invoices for October go out on Friday.</div>
<div class="tabs-patch__panel" role="tabpanel" id="tabs-patch-panel-3" aria-labelledby="tabs-patch-tab-3" hidden="">One request, from a design magazine, about the rebrand.</div>
</div>.tabs-patch { width: 240px; }
.tabs-patch__board { position: relative; height: 132px; border-radius: 12px; background: light-dark(#ebedf0, #232529); }
.tabs-patch__wire { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
.tabs-patch__cord { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 2.5; stroke-linecap: round; }
.tabs-patch__plug { stroke: light-dark(#373a40, #f1f2f3); stroke-width: 6; stroke-linecap: round; }
.tabs-patch__socket { fill: light-dark(#cbced3, #393c41); }
.tabs-patch__list { display: grid; grid-template-columns: repeat(4, 1fr); padding-top: 8px; }
.tabs-patch__tab { display: grid; justify-items: center; gap: 5px; padding: 0; border: 0; background: none; font: inherit; font-size: 11.5px; line-height: 15px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.25s; }
.tabs-patch__tab:hover, .tabs-patch__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-patch__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; border-radius: 6px; }
.tabs-patch__jack { width: 16px; height: 16px; box-sizing: border-box; border: 3px solid light-dark(#6d7179, #7d8087); border-radius: 50%; background: light-dark(#fbfcfd, #121417); }
.tabs-patch__tab[aria-selected='true'] .tabs-patch__jack { border-color: light-dark(#42454c, #e5e6e9); }
.tabs-patch__panel { padding: 12px 0 0; min-height: calc(3.1em + 12px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }function initTabsPatch(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 JACK_Y = 36;
var SOCKET = { x: 120, y: 112 };
var N = 15;
var SEG = 9.2;
var G = 0.3;
var wire = root.querySelector('.tabs-patch__cord');
var plug = root.querySelector('.tabs-patch__plug');
var raf = 0;
function jack(i) { return { x: i * COL + COL / 2, y: JACK_Y }; }
// A cord of N links (verlet): each point keeps its own momentum, gravity pulls it down, and a few
// passes of length constraints keep the links a fixed length. Both ends are held: the switchboard
// socket and the plug.
function simulate(p, end) {
for (var i = 1; i < N; i++) {
var q = p[i];
var vx = (q.x - q.px) * 0.97;
var vy = (q.y - q.py) * 0.97;
q.px = q.x;
q.py = q.y;
q.x += vx;
q.y += vy + G;
}
for (var k = 0; k < 10; k++) {
p[0].x = SOCKET.x;
p[0].y = SOCKET.y;
p[N].x = end.x;
p[N].y = end.y;
for (var j = 0; j < N; j++) {
var a = p[j];
var b = p[j + 1];
var dx = b.x - a.x;
var dy = b.y - a.y;
var d = Math.hypot(dx, dy) || 1;
var f = (d - SEG) / d / 2;
if (j > 0) {
a.x += dx * f;
a.y += dy * f;
}
if (j < N - 1) {
b.x -= dx * f;
b.y -= dy * f;
}
}
}
}
// The cord hanging still from a jack: start it straight and let it settle.
function cord(end) {
var p = [];
for (var i = 0; i <= N; i++) {
var x = SOCKET.x + ((end.x - SOCKET.x) * i) / N;
var y = SOCKET.y + ((end.y - SOCKET.y) * i) / N;
p.push({ x: x, y: y, px: x, py: y });
}
for (var k = 0; k < 400; k++) simulate(p, end);
return p;
}
function draw(p) {
var d = 'M' + p[0].x.toFixed(1) + ' ' + p[0].y.toFixed(1);
for (var i = 1; i < N; i++) d += 'Q' + p[i].x.toFixed(1) + ' ' + p[i].y.toFixed(1) + ' ' + ((p[i].x + p[i + 1].x) / 2).toFixed(1) + ' ' + ((p[i].y + p[i + 1].y) / 2).toFixed(1);
wire.setAttribute('d', d + 'L' + p[N].x.toFixed(1) + ' ' + p[N].y.toFixed(1));
var a = p[N];
var b = p[N - 2];
var len = Math.hypot(a.x - b.x, a.y - b.y) || 1;
plug.setAttribute('x1', a.x);
plug.setAttribute('y1', a.y);
plug.setAttribute('x2', a.x + ((b.x - a.x) / len) * 9);
plug.setAttribute('y2', a.y + ((b.y - a.y) / len) * 9);
}
var pts = cord(jack(sel));
// Moving the call: the plug lifts out, arcs over to the new jack and pushes in, and the cord
// swings after it until it hangs still again.
function move(from, to) {
cancelAnimationFrame(raf);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
pts = cord(to);
return draw(pts);
}
var t0 = performance.now();
var still = 0;
function sum() { return pts.reduce(function (acc, q) { return acc + q.x + q.y; }, 0); }
function step(now) {
var k = Math.min(1, (now - t0) / 600);
var e = k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2;
var before = sum();
simulate(pts, { x: from.x + (to.x - from.x) * e, y: JACK_Y - Math.sin(k * Math.PI) * 18 });
draw(pts);
still = k === 1 && Math.abs(sum() - before) < 0.05 ? still + 1 : 0;
if (still < 10 && now - t0 < 4000) 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; });
move(jack(prev), jack(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);
draw(pts);
return function () {
cancelAnimationFrame(raf);
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.tabs-patch').forEach(initTabsPatch);Goal: Create a "Patch" tab set.
Visual behavior: Phone lines on a switchboard: the plug lifts out, arcs to the new jack and pushes in, and the cord swings after it on real rope physics until it hangs still.
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 cord is 16 points simulated with Verlet integration: each keeps its own momentum, gravity pulls it down, and ten passes of length constraints per frame keep the links a fixed length. One end is held at the socket, the other at the plug.
- The plug follows an eased arc from jack to jack, and the simulation runs on until the cord has stopped moving.
- The cord is drawn as one smooth SVG path through the midpoints of its links, and its resting shape is worked out up front so the first frame matches.
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 plug and cord move straight to the new jack.
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 "Patch" tab set.
Visual behavior: Phone lines on a switchboard: the plug lifts out, arcs to the new jack and pushes in, and the cord swings after it on real rope physics until it hangs still.
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 cord is 16 points simulated with Verlet integration: each keeps its own momentum, gravity pulls it down, and ten passes of length constraints per frame keep the links a fixed length. One end is held at the socket, the other at the plug.
- The plug follows an eased arc from jack to jack, and the simulation runs on until the cord has stopped moving.
- The cord is drawn as one smooth SVG path through the midpoints of its links, and its resting shape is worked out up front so the first frame matches.
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 plug and cord move straight to the new jack.
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
Phone lines on a switchboard: the plug lifts out, arcs to the new jack and pushes in, and the cord swings after it on real rope physics until it hangs still.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- click, state change
- Techniques
- raf, svg
- 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 plug and cord move straight to the new jack.
More tabs
See allCliff 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.