Evening news
Floods in the valley, and the bridge reopens Monday.
Match of the day
Late winner at the harbour ground, 2 to 1.
Late film
A silent-era heist, starting in ten minutes.
import { useEffect, useId, useRef, useState } from 'react';
const TABS = [
{ label: 'Games', body: 'Eight cabinets on the floor today, and two of them are new this week.' },
{ label: 'Prizes', body: 'Three plush toys left on the top shelf, the big one at 900 tickets.' },
{ label: 'Scores', body: 'Your best this month is 42,310, fourth on the board.' },
{ label: 'Friends', body: 'Two friends are playing now, and one has invited you to a race.' },
];
const W = 240;
const H = 88;
const PAD = 8; // keeps the open prongs inside the glass at either end
const COL = (W - 2 * PAD) / TABS.length;
const CHIP_W = 46;
const CHIP_H = 26;
const REST = 54; // chip top, sitting in its slot
const PIVOT = 12; // where the cable leaves the carriage
const HUB = 4; // cable end to the top of the chip it holds
const LOW = REST - PIVOT - HUB; // cable length down on a chip
const DROP = LOW - 7; // lets go a little above the slot
const HIGH = 10; // cable length with the claw up
const OPEN = 24; // prong angle when open, in degrees
const home = (i: number) => PAD + i * COL + COL / 2;
type Chip = { held: boolean; dx: number; dy: number; r: number; vx: number; vy: number; vr: number };
type Pose = { x: number; L: number; th: number; g: number; chips: Chip[] };
const rest = (): Chip => ({ held: false, dx: 0, dy: 0, r: 0, vx: 0, vy: 0, vr: 0 });
// Where the cable ends, swung through th radians about the carriage.
const tip = (p: Pose) => ({ x: p.x - (p.L + HUB) * Math.sin(p.th), y: PIVOT + (p.L + HUB) * Math.cos(p.th) });
const holding = (i: number): Pose => {
const p: Pose = { x: home(i), L: HIGH, th: 0, g: 1, chips: TABS.map(rest) };
p.chips[i] = { ...rest(), held: true, dy: PIVOT + HIGH + HUB - REST };
return p;
};
const css = `
.tabs-claw { width: ${W}px; }
.tabs-claw__machine { position: relative; height: ${H}px; border-radius: 14px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#ebedf0, #232529), inset 0 10px 18px -12px rgb(0 0 0 / 0.5); overflow: hidden; }
.tabs-claw__machine::after { content: ''; position: absolute; inset: 0; border-radius: inherit; background: linear-gradient(112deg, transparent 22%, color-mix(in srgb, light-dark(#373a40, #f1f2f3) 6%, transparent) 22% 34%, transparent 34% 40%, color-mix(in srgb, light-dark(#373a40, #f1f2f3) 4%, transparent) 40% 44%, transparent 44%); pointer-events: none; z-index: 2; }
.tabs-claw__rig { position: absolute; inset: 0; width: ${W}px; height: ${H}px; overflow: visible; pointer-events: none; z-index: 1; }
.tabs-claw__rail { stroke: light-dark(#dbdde1, #2e3035); stroke-width: 2; stroke-linecap: round; }
.tabs-claw__floor { stroke: light-dark(#ebedf0, #232529); stroke-width: 1; }
.tabs-claw__carriage { fill: light-dark(#cbced3, #393c41); }
.tabs-claw__cable { stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.5; }
.tabs-claw__hub { fill: light-dark(#50535b, #c6c8cd); }
.tabs-claw__prong { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; }
.tabs-claw__shadow { position: absolute; top: ${REST + CHIP_H - 2}px; width: 40px; height: 2px; margin-left: -20px; border-radius: 50%; box-shadow: 0 1px 6px 2px rgb(0 0 0 / 0.35); }
.tabs-claw__tab { position: absolute; top: ${REST}px; width: ${CHIP_W}px; height: ${CHIP_H}px; margin-left: ${-CHIP_W / 2}px; padding: 0; border: 0; border-radius: 8px; background: light-dark(#ebedf0, #232529); box-shadow: inset 0 1px 0 color-mix(in srgb, light-dark(#373a40, #f1f2f3) 8%, transparent); font: inherit; font-size: 12px; font-weight: 600; color: light-dark(#50535b, #c6c8cd); cursor: pointer; transform-origin: 50% 0; transition: background-color 0.2s, color 0.2s; }
.tabs-claw__tab:hover { color: light-dark(#373a40, #f1f2f3); }
.tabs-claw__tab[data-held] { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); }
.tabs-claw__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.tabs-claw__panel { padding: 12px 2px 0; min-height: calc(3.1em + 12px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
`;
export function ClawTabs() {
const id = useId();
const [sel, setSel] = useState(0);
const [pose, setPose] = useState<Pose>(() => holding(0));
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const st = useRef({ p: holding(0), vx: 0, vL: 0, vth: 0, vg: 0, closing: false, raf: 0 });
// The claw works through a chore list every frame, so a new pick can cut in at any point: put back
// the chip it holds, rise, ride the gantry over, drop open, close on the chip and lift it out. It
// hangs from a cable, so starting and stopping set it swinging.
useEffect(() => {
const s = st.current;
const p = s.p;
cancelAnimationFrame(s.raf);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
Object.assign(s, { p: holding(sel), vx: 0, vL: 0, vth: 0, vg: 0, closing: false });
setPose(s.p);
return;
}
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
const held = p.chips.findIndex((c) => c.held);
let tx = p.x;
let tL = HIGH;
let tg = p.g;
if (held >= 0 && held !== sel) {
tL = DROP;
tg = p.L > DROP - 1.5 ? 0 : 1;
if (p.g < 0.6) {
// Let go: the chip drops the last few pixels into its slot.
const t = tip(p);
p.chips[held] = { held: false, dx: t.x - home(held), dy: t.y - REST, r: p.th, vx: 0, vy: s.vL * Math.cos(p.th), vr: s.vth };
}
} else if (held < 0) {
tg = 0;
if (Math.abs(p.x - home(sel)) > 2.5 || Math.abs(s.vx) > 60) {
s.closing = false;
if (p.L < HIGH + 10) tx = home(sel);
} else {
tx = home(sel);
tL = LOW;
// Only close once it is down on the chip, then keep closing.
s.closing ||= p.L > LOW - 1;
if (s.closing) tg = 1;
if (p.g > 0.92) {
p.chips[sel] = { ...rest(), held: true };
s.closing = false;
}
}
} else {
tx = home(sel);
tg = 1;
}
const ax = 170 * (tx - p.x) - 26 * s.vx;
s.vx += ax * dt;
p.x += s.vx * dt;
s.vL += (650 * (tL - p.L) - 51 * s.vL) * dt;
p.L += s.vL * dt;
s.vg += (1100 * (tg - p.g) - 66 * s.vg) * dt;
p.g = Math.max(0, Math.min(1, p.g + s.vg * dt));
// A pendulum on the cable: shorter swings faster, and the carriage's pull sets it going.
s.vth += ((-2400 / (p.L + 20)) * p.th - 5 * s.vth + 0.0008 * ax) * dt;
p.th += s.vth * dt;
let busy = Math.abs(p.x - home(sel)) > 0.2 || Math.abs(s.vx) > 1 || Math.abs(p.L - HIGH) > 0.2 || Math.abs(s.vL) > 1;
busy ||= p.g < 0.99 || Math.abs(p.th) > 0.002 || Math.abs(s.vth) > 0.02 || held !== sel;
const t = tip(p);
p.chips.forEach((c, i) => {
if (c.held) {
Object.assign(c, { dx: t.x - home(i), dy: t.y - REST, r: p.th });
return;
}
// Loose chips fall under gravity, bounce once or twice and straighten up.
c.vy += 2600 * dt;
c.dy += c.vy * dt;
if (c.dy > 0) {
c.dy = 0;
c.vy = Math.abs(c.vy) < 40 ? 0 : -c.vy * 0.35;
}
c.vx += (-300 * c.dx - 30 * c.vx) * dt;
c.dx += c.vx * dt;
c.vr += (-500 * c.r - 26 * c.vr) * dt;
c.r += c.vr * dt;
busy ||= c.dy < -0.1 || Math.abs(c.vy) > 0 || Math.abs(c.dx) > 0.1 || Math.abs(c.r) > 0.002;
});
if (!busy) {
Object.assign(s, { p: holding(sel), vx: 0, vL: 0, vth: 0, vg: 0, closing: false });
setPose(s.p);
return;
}
setPose({ ...p, chips: p.chips.map((c) => ({ ...c })) });
s.raf = requestAnimationFrame(step);
};
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();
};
const open = OPEN * (1 - pose.g);
return (
<>
<style>
{css}
</style>
<div className="tabs-claw">
<div className="tabs-claw__machine" role="tablist" aria-label="Arcade" onKeyDown={onKeyDown}>
{TABS.map((t, i) => {
const c = pose.chips[i];
const lift = Math.min(1, -c.dy / 30);
return (
<span
key={t.label}
className="tabs-claw__shadow"
aria-hidden="true"
style={{ left: home(i) + c.dx, opacity: 1 - lift * 0.75, transform: `scaleX(${1 - lift * 0.45})` }}
/>
);
})}
{TABS.map((t, i) => {
const c = pose.chips[i];
return (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-claw__tab"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={sel === i ? 0 : -1}
data-held={c.held ? '' : undefined}
style={{ left: home(i), transform: `translate(${c.dx}px, ${c.dy}px) rotate(${c.r}rad)` }}
onClick={() => setSel(i)}
>
{t.label}
</button>
);
})}
<svg className="tabs-claw__rig" viewBox={`0 0 ${W} ${H}`} aria-hidden="true">
<line className="tabs-claw__floor" x1="0" x2={W} y1={REST + CHIP_H + 0.5} y2={REST + CHIP_H + 0.5} />
<line className="tabs-claw__rail" x1="10" x2={W - 10} y1="8" y2="8" />
<rect className="tabs-claw__carriage" x={pose.x - 11} y="3" width="22" height="9" rx="3" />
<g transform={`translate(${pose.x} ${PIVOT}) rotate(${(pose.th * 180) / Math.PI})`}>
<line className="tabs-claw__cable" x1="0" y1="0" x2="0" y2={pose.L} />
<g transform={`translate(0 ${pose.L})`}>
<rect className="tabs-claw__hub" x="-11" y="-3" width="22" height="7" rx="2" />
<path className="tabs-claw__prong" transform={`translate(-9 2) rotate(${open})`} d="M0 0C-8 1-16 5-16 11Q-16 17-12.5 20" />
<path className="tabs-claw__prong" transform={`translate(9 2) rotate(${-open})`} d="M0 0C8 1 16 5 16 11Q16 17 12.5 20" />
</g>
</g>
</svg>
</div>
{TABS.map((t, i) => (
<div key={t.label} className="tabs-claw__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 initTabsClaw(el) on the mounted element instead.
<div class="tabs-claw">
<div class="tabs-claw__machine" role="tablist" aria-label="Arcade">
<span class="tabs-claw__shadow" aria-hidden="true" style="left:36px;opacity:0.3;transform:scaleX(0.58)"></span>
<span class="tabs-claw__shadow" aria-hidden="true" style="left:92px;opacity:1;transform:scaleX(1)"></span>
<span class="tabs-claw__shadow" aria-hidden="true" style="left:148px;opacity:1;transform:scaleX(1)"></span>
<span class="tabs-claw__shadow" aria-hidden="true" style="left:204px;opacity:1;transform:scaleX(1)"></span>
<button class="tabs-claw__tab" type="button" role="tab" id="tabs-claw-tab-0" aria-selected="true" aria-controls="tabs-claw-panel-0" tabindex="0" data-held="" style="left:36px;transform:translate(0px, -28px) rotate(0rad)">Games</button>
<button class="tabs-claw__tab" type="button" role="tab" id="tabs-claw-tab-1" aria-selected="false" aria-controls="tabs-claw-panel-1" tabindex="-1" style="left:92px;transform:translate(0px, 0px) rotate(0rad)">Prizes</button>
<button class="tabs-claw__tab" type="button" role="tab" id="tabs-claw-tab-2" aria-selected="false" aria-controls="tabs-claw-panel-2" tabindex="-1" style="left:148px;transform:translate(0px, 0px) rotate(0rad)">Scores</button>
<button class="tabs-claw__tab" type="button" role="tab" id="tabs-claw-tab-3" aria-selected="false" aria-controls="tabs-claw-panel-3" tabindex="-1" style="left:204px;transform:translate(0px, 0px) rotate(0rad)">Friends</button>
<svg class="tabs-claw__rig" viewBox="0 0 240 88" aria-hidden="true">
<line class="tabs-claw__floor" x1="0" x2="240" y1="80.5" y2="80.5"></line>
<line class="tabs-claw__rail" x1="10" x2="230" y1="8" y2="8"></line>
<rect class="tabs-claw__carriage" x="25" y="3" width="22" height="9" rx="3"></rect>
<g transform="translate(36 12) rotate(0)">
<line class="tabs-claw__cable" x1="0" y1="0" x2="0" y2="10"></line>
<g transform="translate(0 10)">
<rect class="tabs-claw__hub" x="-11" y="-3" width="22" height="7" rx="2"></rect>
<path class="tabs-claw__prong" transform="translate(-9 2) rotate(0)" d="M0 0C-8 1-16 5-16 11Q-16 17-12.5 20"></path>
<path class="tabs-claw__prong" transform="translate(9 2) rotate(0)" d="M0 0C8 1 16 5 16 11Q16 17 12.5 20"></path>
</g>
</g>
</svg>
</div>
<div class="tabs-claw__panel" role="tabpanel" id="tabs-claw-panel-0" aria-labelledby="tabs-claw-tab-0">Eight cabinets on the floor today, and two of them are new this week.</div>
<div class="tabs-claw__panel" role="tabpanel" id="tabs-claw-panel-1" aria-labelledby="tabs-claw-tab-1" hidden="">Three plush toys left on the top shelf, the big one at 900 tickets.</div>
<div class="tabs-claw__panel" role="tabpanel" id="tabs-claw-panel-2" aria-labelledby="tabs-claw-tab-2" hidden="">Your best this month is 42,310, fourth on the board.</div>
<div class="tabs-claw__panel" role="tabpanel" id="tabs-claw-panel-3" aria-labelledby="tabs-claw-tab-3" hidden="">Two friends are playing now, and one has invited you to a race.</div>
</div>.tabs-claw { width: 240px; }
.tabs-claw__machine { position: relative; height: 88px; border-radius: 14px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#ebedf0, #232529), inset 0 10px 18px -12px rgb(0 0 0 / 0.5); overflow: hidden; }
.tabs-claw__machine::after { content: ''; position: absolute; inset: 0; border-radius: inherit; background: linear-gradient(112deg, transparent 22%, color-mix(in srgb, light-dark(#373a40, #f1f2f3) 6%, transparent) 22% 34%, transparent 34% 40%, color-mix(in srgb, light-dark(#373a40, #f1f2f3) 4%, transparent) 40% 44%, transparent 44%); pointer-events: none; z-index: 2; }
.tabs-claw__rig { position: absolute; inset: 0; width: 240px; height: 88px; overflow: visible; pointer-events: none; z-index: 1; }
.tabs-claw__rail { stroke: light-dark(#dbdde1, #2e3035); stroke-width: 2; stroke-linecap: round; }
.tabs-claw__floor { stroke: light-dark(#ebedf0, #232529); stroke-width: 1; }
.tabs-claw__carriage { fill: light-dark(#cbced3, #393c41); }
.tabs-claw__cable { stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.5; }
.tabs-claw__hub { fill: light-dark(#50535b, #c6c8cd); }
.tabs-claw__prong { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; }
.tabs-claw__shadow { position: absolute; top: 78px; width: 40px; height: 2px; margin-left: -20px; border-radius: 50%; box-shadow: 0 1px 6px 2px rgb(0 0 0 / 0.35); }
.tabs-claw__tab { position: absolute; top: 54px; width: 46px; height: 26px; margin-left: -23px; padding: 0; border: 0; border-radius: 8px; background: light-dark(#ebedf0, #232529); box-shadow: inset 0 1px 0 color-mix(in srgb, light-dark(#373a40, #f1f2f3) 8%, transparent); font: inherit; font-size: 12px; font-weight: 600; color: light-dark(#50535b, #c6c8cd); cursor: pointer; transform-origin: 50% 0; transition: background-color 0.2s, color 0.2s; }
.tabs-claw__tab:hover { color: light-dark(#373a40, #f1f2f3); }
.tabs-claw__tab[data-held] { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); }
.tabs-claw__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.tabs-claw__panel { padding: 12px 2px 0; min-height: calc(3.1em + 12px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }function initTabsClaw(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 W = 240;
var PAD = 8;
var COL = (W - 2 * PAD) / n;
var REST = 54; // chip top, sitting in its slot
var PIVOT = 12; // where the cable leaves the carriage
var HUB = 4; // cable end to the top of the chip it holds
var LOW = REST - PIVOT - HUB; // cable length down on a chip
var DROP = LOW - 7; // lets go a little above the slot
var HIGH = 10; // cable length with the claw up
var OPEN = 24; // prong angle when open, in degrees
var shadows = root.querySelectorAll('.tabs-claw__shadow');
var carriage = root.querySelector('.tabs-claw__carriage');
var swing = root.querySelector('.tabs-claw__rig > g');
var cable = root.querySelector('.tabs-claw__cable');
var head = cable.nextElementSibling;
var prongs = root.querySelectorAll('.tabs-claw__prong');
var raf = 0;
function home(i) { return PAD + i * COL + COL / 2; }
function loose() { return { held: false, dx: 0, dy: 0, r: 0, vx: 0, vy: 0, vr: 0 }; }
function holding(i) {
var chips = tabs.map(loose);
chips[i].held = true;
chips[i].dy = PIVOT + HIGH + HUB - REST;
return { x: home(i), L: HIGH, th: 0, g: 1, vx: 0, vL: 0, vth: 0, vg: 0, closing: false, chips: chips };
}
var p = holding(sel);
// Where the cable ends, swung through th radians about the carriage.
function tip() { return { x: p.x - (p.L + HUB) * Math.sin(p.th), y: PIVOT + (p.L + HUB) * Math.cos(p.th) }; }
function draw() {
var open = OPEN * (1 - p.g);
carriage.setAttribute('x', p.x - 11);
swing.setAttribute('transform', 'translate(' + p.x + ' ' + PIVOT + ') rotate(' + (p.th * 180) / Math.PI + ')');
cable.setAttribute('y2', p.L);
head.setAttribute('transform', 'translate(0 ' + p.L + ')');
prongs[0].setAttribute('transform', 'translate(-9 2) rotate(' + open + ')');
prongs[1].setAttribute('transform', 'translate(9 2) rotate(' + -open + ')');
p.chips.forEach(function (c, i) {
var lift = Math.min(1, -c.dy / 30);
tabs[i].style.transform = 'translate(' + c.dx + 'px, ' + c.dy + 'px) rotate(' + c.r + 'rad)';
tabs[i].toggleAttribute('data-held', c.held);
shadows[i].style.left = home(i) + c.dx + 'px';
shadows[i].style.opacity = String(1 - lift * 0.75);
shadows[i].style.transform = 'scaleX(' + (1 - lift * 0.45) + ')';
});
}
// The claw works through a chore list every frame, so a new pick can cut in at any point: put back
// the chip it holds, rise, ride the gantry over, drop open, close on the chip and lift it out. It
// hangs from a cable, so starting and stopping set it swinging.
function step(now, last) {
var dt = Math.min(0.032, (now - last) / 1000);
var held = p.chips.findIndex(function (c) { return c.held; });
var tx = p.x;
var tL = HIGH;
var tg = p.g;
var t;
if (held >= 0 && held !== sel) {
tL = DROP;
tg = p.L > DROP - 1.5 ? 0 : 1;
if (p.g < 0.6) {
// Let go: the chip drops the last few pixels into its slot.
t = tip();
p.chips[held] = { held: false, dx: t.x - home(held), dy: t.y - REST, r: p.th, vx: 0, vy: p.vL * Math.cos(p.th), vr: p.vth };
}
} else if (held < 0) {
tg = 0;
if (Math.abs(p.x - home(sel)) > 2.5 || Math.abs(p.vx) > 60) {
p.closing = false;
if (p.L < HIGH + 10) tx = home(sel);
} else {
tx = home(sel);
tL = LOW;
// Only close once it is down on the chip, then keep closing.
p.closing = p.closing || p.L > LOW - 1;
if (p.closing) tg = 1;
if (p.g > 0.92) {
p.chips[sel] = loose();
p.chips[sel].held = true;
p.closing = false;
}
}
} else {
tx = home(sel);
tg = 1;
}
var ax = 170 * (tx - p.x) - 26 * p.vx;
p.vx += ax * dt;
p.x += p.vx * dt;
p.vL += (650 * (tL - p.L) - 51 * p.vL) * dt;
p.L += p.vL * dt;
p.vg += (1100 * (tg - p.g) - 66 * p.vg) * dt;
p.g = Math.max(0, Math.min(1, p.g + p.vg * dt));
// A pendulum on the cable: shorter swings faster, and the carriage's pull sets it going.
p.vth += ((-2400 / (p.L + 20)) * p.th - 5 * p.vth + 0.0008 * ax) * dt;
p.th += p.vth * dt;
var busy = Math.abs(p.x - home(sel)) > 0.2 || Math.abs(p.vx) > 1 || Math.abs(p.L - HIGH) > 0.2 || Math.abs(p.vL) > 1;
busy = busy || p.g < 0.99 || Math.abs(p.th) > 0.002 || Math.abs(p.vth) > 0.02 || held !== sel;
t = tip();
p.chips.forEach(function (c, i) {
if (c.held) {
c.dx = t.x - home(i);
c.dy = t.y - REST;
c.r = p.th;
return;
}
// Loose chips fall under gravity, bounce once or twice and straighten up.
c.vy += 2600 * dt;
c.dy += c.vy * dt;
if (c.dy > 0) {
c.dy = 0;
c.vy = Math.abs(c.vy) < 40 ? 0 : -c.vy * 0.35;
}
c.vx += (-300 * c.dx - 30 * c.vx) * dt;
c.dx += c.vx * dt;
c.vr += (-500 * c.r - 26 * c.vr) * dt;
c.r += c.vr * dt;
busy = busy || c.dy < -0.1 || Math.abs(c.vy) > 0 || Math.abs(c.dx) > 0.1 || Math.abs(c.r) > 0.002;
});
if (!busy) p = holding(sel);
draw();
if (busy) raf = requestAnimationFrame(function (next) { step(next, now); });
}
function run() {
cancelAnimationFrame(raf);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
p = holding(sel);
return draw();
}
var now = performance.now();
raf = requestAnimationFrame(function (next) { step(next, now); });
}
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; });
run();
}
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-claw').forEach(initTabsClaw);Goal: Create a "Claw" tab set.
Visual behavior: Arcade tabs in a claw machine: the claw lowers the tab it holds back into its slot, rides the gantry to the one you pick, swinging on its cable, then grips it and lifts it out.
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.
- Each frame the claw works out its next chore from where it is: lower and open to put back the chip it holds, rise, ride the gantry over, lower open, close on the chosen chip and lift it. Position, cable length and grip each follow a damped spring towards the chore's target, so a new pick can cut in at any point.
- The claw hangs from the carriage as a pendulum: the carriage's acceleration sets it swinging, and a shorter cable swings faster. A held chip follows the end of the cable and swings with it.
- A chip let go falls the last few pixels under gravity, bounces once or twice and straightens, while its shadow on the floor shrinks and fades the higher it is lifted.
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. The panel changes as soon as a tab is picked; the claw catches up. Under prefers-reduced-motion the claw holds the chosen chip at once.
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 "Claw" tab set.
Visual behavior: Arcade tabs in a claw machine: the claw lowers the tab it holds back into its slot, rides the gantry to the one you pick, swinging on its cable, then grips it and lifts it out.
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.
- Each frame the claw works out its next chore from where it is: lower and open to put back the chip it holds, rise, ride the gantry over, lower open, close on the chosen chip and lift it. Position, cable length and grip each follow a damped spring towards the chore's target, so a new pick can cut in at any point.
- The claw hangs from the carriage as a pendulum: the carriage's acceleration sets it swinging, and a shorter cable swings faster. A held chip follows the end of the cable and swings with it.
- A chip let go falls the last few pixels under gravity, bounces once or twice and straightens, while its shadow on the floor shrinks and fades the higher it is lifted.
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. The panel changes as soon as a tab is picked; the claw catches up. Under prefers-reduced-motion the claw holds the chosen chip at once.
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
Arcade tabs in a claw machine: the claw lowers the tab it holds back into its slot, rides the gantry to the one you pick, swinging on its cable, then grips it and lifts it out.
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. The panel changes as soon as a tab is picked; the claw catches up. Under prefers-reduced-motion the claw holds the chosen chip at once.
Evening news
Floods in the valley, and the bridge reopens Monday.
Match of the day
Late winner at the harbour ground, 2 to 1.
Late film
A silent-era heist, starting in ten minutes.