Hang
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
const TABS = [
{ label: 'Studio', body: 'North light, a long oak table and room for six.' },
{ label: 'Gallery', body: 'White walls, a polished concrete floor and track lighting.' },
{ label: 'Garden', body: 'A walled garden with a pergola, open until September.' },
];
const W = 264;
const PANEL = 200;
const DROP = 34;
const css = `
.tabs-hang { position: relative; width: ${W}px; }
.tabs-hang__list { display: grid; grid-template-columns: repeat(${TABS.length}, 1fr); }
.tabs-hang__tab { position: relative; border: 0; padding: 6px 0 10px; background: none; font: inherit; font-size: 13px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.3s; }
.tabs-hang__tab:hover, .tabs-hang__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-hang__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; border-radius: 6px; }
.tabs-hang__stage { position: relative; padding-top: ${DROP}px; }
.tabs-hang__rig { position: absolute; left: 0; top: 0; width: ${W}px; height: ${DROP}px; overflow: visible; pointer-events: none; }
.tabs-hang__cord { stroke: light-dark(#6d7179, #7d8087); stroke-width: 1; fill: none; }
.tabs-hang__hook { fill: light-dark(#fbfcfd, #121417); stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.5; }
.tabs-hang__panel {
position: relative;
width: ${PANEL}px;
margin: 0 auto;
padding: 12px 14px;
min-height: 82px;
border-radius: 10px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#ebedf0, #232529), 0 8px 18px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.35));
color: light-dark(#50535b, #c6c8cd);
font-size: 13px;
line-height: 1.55;
transform-origin: 50% ${-DROP}px;
}
`;
export function HangTabs() {
const id = useId();
const [sel, setSel] = useState(1);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const panels = useRef<(HTMLDivElement | null)[]>([]);
const svg = useRef<SVGSVGElement>(null);
const sim = useRef({ hook: 0, hv: 0, angle: 0, av: 0, raf: 0 });
const target = (i: number) => {
const t = tabs.current[i]!;
return t.offsetLeft + t.offsetWidth / 2;
};
// Draws the hook, the two cords to the panel's top corners, and turns the panel to the swing angle.
const draw = () => {
const s = sim.current;
const p = panels.current[sel];
// The panel drifts a third of the way towards the hook, so the cords never lie flat.
const shift = (s.hook - W / 2) * 0.34;
if (p) p.style.transform = `translateX(${shift}px) rotate(${s.angle}rad)`;
// The panel turns about a point DROP above its top centre; its corners swing with it.
const corner = (dx: number) => [W / 2 + shift + dx * Math.cos(s.angle) - DROP * Math.sin(s.angle), dx * Math.sin(s.angle) + DROP * Math.cos(s.angle)];
const ends = [corner(-PANEL / 2 + 16), corner(PANEL / 2 - 16)];
const g = svg.current!;
g.querySelector('.tabs-hang__hook')!.setAttribute('cx', String(s.hook));
g.querySelectorAll('.tabs-hang__cord').forEach((c, k) => c.setAttribute('d', `M${s.hook} 0L${ends[k][0]} ${ends[k][1]}`));
};
useLayoutEffect(() => {
sim.current.hook = target(sel);
draw();
document.fonts?.ready.then(() => {
if (!sim.current.raf) {
sim.current.hook = target(sel);
draw();
}
});
}, []);
// The hook slides on a spring; the panel is a pendulum, pushed by how hard the hook accelerates.
useEffect(() => {
const s = sim.current;
const goal = target(sel);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
Object.assign(s, { hook: goal, hv: 0, angle: 0, av: 0 });
draw();
return;
}
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
const acc = -150 * (s.hook - goal) - 20 * s.hv;
s.hv += acc * dt;
s.hook += s.hv * dt;
s.av += (-30 * Math.sin(s.angle) - 3.4 * s.av - acc * 0.0024) * dt;
s.angle += s.av * dt;
draw();
if (Math.abs(s.hook - goal) < 0.05 && Math.abs(s.hv) < 0.5 && Math.abs(s.angle) < 0.0008 && Math.abs(s.av) < 0.002) {
Object.assign(s, { hook: goal, hv: 0, angle: 0, av: 0, raf: 0 });
draw();
return;
}
s.raf = requestAnimationFrame(step);
};
cancelAnimationFrame(s.raf);
s.raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(s.raf);
}, [sel]);
const n = TABS.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-hang">
<div className="tabs-hang__list" role="tablist" aria-label="Spaces" onKeyDown={onKeyDown}>
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-hang__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>
))}
</div>
<div className="tabs-hang__stage">
<svg className="tabs-hang__rig" ref={svg} aria-hidden="true">
<path className="tabs-hang__cord" />
<path className="tabs-hang__cord" />
<circle className="tabs-hang__hook" cx={W / 2} cy={0} r={3} />
</svg>
{TABS.map((t, i) => (
<div
key={t.label}
ref={(el) => {
panels.current[i] = el;
}}
className="tabs-hang__panel"
role="tabpanel"
id={`${id}-panel-${i}`}
aria-labelledby={`${id}-tab-${i}`}
hidden={sel !== i}
>
{t.body}
</div>
))}
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsHang(el) on the mounted element instead.
<div class="tabs-hang">
<div class="tabs-hang__list" role="tablist" aria-label="Spaces">
<button class="tabs-hang__tab" type="button" role="tab" id="tabs-hang-tab-0" aria-selected="false" aria-controls="tabs-hang-panel-0" tabindex="-1">Studio</button>
<button class="tabs-hang__tab" type="button" role="tab" id="tabs-hang-tab-1" aria-selected="true" aria-controls="tabs-hang-panel-1" tabindex="0">Gallery</button>
<button class="tabs-hang__tab" type="button" role="tab" id="tabs-hang-tab-2" aria-selected="false" aria-controls="tabs-hang-panel-2" tabindex="-1">Garden</button>
</div>
<div class="tabs-hang__stage">
<svg class="tabs-hang__rig" aria-hidden="true">
<path class="tabs-hang__cord"></path>
<path class="tabs-hang__cord"></path>
<circle class="tabs-hang__hook" cx="132" cy="0" r="3"></circle>
</svg>
<div class="tabs-hang__panel" role="tabpanel" id="tabs-hang-panel-0" aria-labelledby="tabs-hang-tab-0" hidden="">North light, a long oak table and room for six.</div>
<div class="tabs-hang__panel" role="tabpanel" id="tabs-hang-panel-1" aria-labelledby="tabs-hang-tab-1">White walls, a polished concrete floor and track lighting.</div>
<div class="tabs-hang__panel" role="tabpanel" id="tabs-hang-panel-2" aria-labelledby="tabs-hang-tab-2" hidden="">A walled garden with a pergola, open until September.</div>
</div>
</div>.tabs-hang { position: relative; width: 264px; }
.tabs-hang__list { display: grid; grid-template-columns: repeat(3, 1fr); }
.tabs-hang__tab { position: relative; border: 0; padding: 6px 0 10px; background: none; font: inherit; font-size: 13px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.3s; }
.tabs-hang__tab:hover, .tabs-hang__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-hang__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; border-radius: 6px; }
.tabs-hang__stage { position: relative; padding-top: 34px; }
.tabs-hang__rig { position: absolute; left: 0; top: 0; width: 264px; height: 34px; overflow: visible; pointer-events: none; }
.tabs-hang__cord { stroke: light-dark(#6d7179, #7d8087); stroke-width: 1; fill: none; }
.tabs-hang__hook { fill: light-dark(#fbfcfd, #121417); stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.5; }
.tabs-hang__panel {
position: relative;
width: 200px;
margin: 0 auto;
padding: 12px 14px;
min-height: 82px;
border-radius: 10px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#ebedf0, #232529), 0 8px 18px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.35));
color: light-dark(#50535b, #c6c8cd);
font-size: 13px;
line-height: 1.55;
transform-origin: 50% -34px;
}function initTabsHang(root) {
var W = 264;
var PANEL = 200;
var DROP = 34;
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = root.querySelectorAll('[role="tabpanel"]');
var hook = root.querySelector('.tabs-hang__hook');
var cords = root.querySelectorAll('.tabs-hang__cord');
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
var s = { hook: 0, hv: 0, angle: 0, av: 0 };
var raf = 0;
function target(i) { return tabs[i].offsetLeft + tabs[i].offsetWidth / 2; }
// Draws the hook, the two cords to the panel's top corners, and turns the panel to the swing angle.
function draw() {
// The panel drifts a third of the way towards the hook, so the cords never lie flat.
var shift = (s.hook - W / 2) * 0.34;
panels[sel].style.transform = 'translateX(' + shift + 'px) rotate(' + s.angle + 'rad)';
// The panel turns about a point DROP above its top centre; its corners swing with it.
[-PANEL / 2 + 16, PANEL / 2 - 16].forEach(function (dx, k) {
var x = W / 2 + shift + dx * Math.cos(s.angle) - DROP * Math.sin(s.angle);
var y = dx * Math.sin(s.angle) + DROP * Math.cos(s.angle);
cords[k].setAttribute('d', 'M' + s.hook + ' 0L' + x + ' ' + y);
});
hook.setAttribute('cx', s.hook);
}
// The hook slides on a spring; the panel is a pendulum, pushed by how hard the hook accelerates.
function run() {
cancelAnimationFrame(raf);
var goal = target(sel);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
s = { hook: goal, hv: 0, angle: 0, av: 0 };
return draw();
}
var last = performance.now();
function step(now) {
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
var acc = -150 * (s.hook - goal) - 20 * s.hv;
s.hv += acc * dt;
s.hook += s.hv * dt;
s.av += (-30 * Math.sin(s.angle) - 3.4 * s.av - acc * 0.0024) * dt;
s.angle += s.av * dt;
if (Math.abs(s.hook - goal) < 0.05 && Math.abs(s.hv) < 0.5 && Math.abs(s.angle) < 0.0008 && Math.abs(s.av) < 0.002) {
s = { hook: goal, hv: 0, angle: 0, av: 0 };
raf = 0;
return draw();
}
draw();
raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
function select(i) {
if (i === sel) return;
panels[sel].style.transform = '';
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; });
run();
}
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 place() {
if (raf) return;
s.hook = target(sel);
draw();
}
list.addEventListener('click', onClick);
list.addEventListener('keydown', onKeyDown);
place();
if (document.fonts) document.fonts.ready.then(place);
return function () {
cancelAnimationFrame(raf);
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.tabs-hang').forEach(initTabsHang);Goal: Create a "Hang" tab set.
Visual behavior: Venue tabs with the panel hung from the active tab on two cords: pick another and the hook slides over, setting the panel swinging before it settles.
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 hook moves to the active tab on a spring, and the panel is a damped pendulum driven by the hook's acceleration, so a quick slide kicks it into a swing. The panel drifts a third of the way towards the hook and rotates about a point above its top edge.
- Two SVG lines run from the hook to the panel's rotated top corners every frame.
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 hook moves and the panel never swings.
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 "Hang" tab set.
Visual behavior: Venue tabs with the panel hung from the active tab on two cords: pick another and the hook slides over, setting the panel swinging before it settles.
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 hook moves to the active tab on a spring, and the panel is a damped pendulum driven by the hook's acceleration, so a quick slide kicks it into a swing. The panel drifts a third of the way towards the hook and rotates about a point above its top edge.
- Two SVG lines run from the hook to the panel's rotated top corners every frame.
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 hook moves and the panel never swings.
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
Venue tabs with the panel hung from the active tab on two cords: pick another and the hook slides over, setting the panel swinging before it settles.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- click, state change, spring
- Techniques
- svg, raf, spring
- 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 hook moves and the panel never swings.