Orbit Loop
Twelve integration nodes circle an accent hub on three rings at 1, 2/3 and 2/5 speed. Hover or focus one and the whole system eases to a stop, a dashed line links it to the core and its details slide in.
- SVG
- gsap
- svg
- intermediate
- light perf
- mobile: full
- reduced-motion ✓
AI prompt
Recreate this effect with any AI tool
Paste into Claude, Cursor, v0, Lovable or Bolt. 1322 words of exact values — rewritten live as you tune the Playground.
### EFFECT: Orbit Loop
**What it does (visual):** A two-column dark page. Left: mono eyebrow, a large serif headline "One core, *twelve* integrations." (*twelve* italic in `#d4ff3a`), a muted paragraph and a detail card (ring number in accent, category, service name in serif, one-line description). Right: a square stage with three concentric rings (inner and outer hairlines, the middle one dotted) around a 124px `#d4ff3a` hub disc that reads "Atlas / CORE" with two soft accent halos. Twelve 52px circular chips — 3 on the inner ring, 4 on the middle, 5 on the outer, each a line icon with a mono uppercase label underneath — circle the hub continuously and linearly: the inner ring makes one revolution every 24s, the middle ring every 1.5 × 24s and the outer every 2.5 × 24s (ring direction `same`: `alternate` sends the middle ring the other way). Nodes on each ring are evenly spaced and every ring is offset by 30° from the previous one. Ring radii are 20%, 32.5% and 45% of the stage size × 1. With tilt 0° the rings flatten into ellipses (`ry = rx × cos(tilt)`) and nodes at the front grow up to 16%, become fully opaque and pass in front of the hub while the back ones shrink, fade to 62% and slip behind it. Orientation `upright`: `upright` keeps every label level, `tangent` turns each chip to face its direction of travel. Hovering, focusing or clicking a chip eases the whole system to a stop in 0.6s, fills that chip with the accent (scale 1.14), draws a dashed accent line from the hub to it and slides its details into the card; leaving eases the orbit back up over 0.8s. A pill dock has Pause/Play and the readout "24 s / inner orbit". On load, a slow tour (orbit at 0.35×) highlights a node every 2.6s until the first pointerdown or key press.
**Tech stack:**
- GSAP 3.12+ core (`https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js` or `npm i gsap` → `import gsap from "gsap"`).
- An absolutely positioned SVG for the ring ellipses and the connector line; nodes are HTML `<button>`s inside a `<ul>` (keyboard-accessible) positioned with `transform`.
- Fonts: serif display (demo: Fraunces), mono labels (JetBrains Mono), sans body (Inter Tight).
**HTML structure:**
```html
<div class="orb">
<section class="orb__copy">
<h1 class="orb__h">One core, <em>twelve</em> integrations.</h1>
<div class="orb__detail" aria-live="polite">
<p class="orb__detail-k"><span class="orb__detail-ring">Ring 01</span> · <span class="orb__detail-cat">Identity</span></p>
<p class="orb__detail-name">Atlas core</p><p class="orb__detail-desc">…</p>
</div>
</section>
<section class="orb__stage" aria-label="Integration ecosystem">
<svg class="orb__rings" aria-hidden="true">
<ellipse class="orb__ring" data-ring="0"/><ellipse class="orb__ring" data-ring="1"/><ellipse class="orb__ring" data-ring="2"/>
<line class="orb__link"/>
</svg>
<div class="orb__hub" aria-hidden="true"><span class="orb__hub-name">Atlas</span><span class="orb__hub-sub">core</span></div>
<ul class="orb__nodes" role="list">
<!-- ×12, generated from data: { ring, name, cat, icon, desc } -->
<li class="orb__item"><button class="orb__node" type="button" aria-label="Auth — Identity">
<span class="orb__chip"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 11V8a5 5 0 0 1 10 0v3M5 11h14v9H5z"/></svg></span>
<span class="orb__label" aria-hidden="true">Auth</span></button></li>
</ul>
</section>
<div class="orb__dock" role="group" aria-label="Orbit controls">
<button class="orb__btn orb__pause" type="button" aria-pressed="false">Pause</button>
<span class="orb__readout"><b>24</b> s / inner orbit</span>
</div>
</div>
```
Nodes: inner — Auth, Payments, Search; middle — Mail, Storage, Analytics, Maps; outer — CMS, Chat, Calendar, Video, Forms.
**CSS requirements:**
- `.orb`: `display: grid; grid-template-columns: minmax(0,.8fr) minmax(0,1.2fr); align-items: center; min-height: 100vh; overflow: hidden; background: var(--bg)`. Under 900px: one column, `--orb-node: 40px`, labels hidden except on the active/focused node.
- `.orb__stage`: `position: relative; width: 100%; max-width: 680px; aspect-ratio: 1`. `.orb__rings`: `position: absolute; inset: 0; width: 100%; height: 100%; max-width: none; overflow: visible; pointer-events: none`.
- Rings: `fill: none; stroke: color-mix(in srgb, var(--text) 13%, transparent)`; middle ring `stroke-dasharray: 2 6` at 22%. Link: `stroke: var(--accent); stroke-dasharray: 3 4; opacity: 0 → .9` (transition .35s) when active.
- `.orb__hub`: centred with `translate(-50%,-50%)`, `width: clamp(64px, 17%, 124px); aspect-ratio: 1; border-radius: 50%; background: var(--accent); z-index: 50`, halos `box-shadow: 0 0 0 10px accent/10%, 0 0 0 22px accent/5%`.
- `.orb__item`: `position: absolute; left: 0; top: 0; width/height: var(--orb-node); margin: calc(var(--orb-node) / -2) 0 0 calc(var(--orb-node) / -2); will-change: transform` (so `translate(x, y)` targets the chip centre).
- `.orb__chip`: 1px border at 16% text, `var(--surface)` fill, icon at 44% size, stroke 1.6. Active / hover / focus-visible: `background: var(--accent); color: #0A0A0A; scale: 1.14` (transition .5s `cubic-bezier(.16,1,.3,1)`), focus ring `0 0 0 3px var(--bg), 0 0 0 5px var(--accent)`.
**Animation spec (exact values):**
- `RINGS = [{ r: .2, speed: 1 }, { r: .325, speed: 2/3 }, { r: .45, speed: 2/5 }]`; `CYCLE = 15` (15, 10 and 6 whole turns).
- Clock: `gsap.fromTo(clock, { t: 0 }, { t: 15, duration: 24 × 15, ease: 'none', repeat: -1, onUpdate: render })`.
- `render()`: for node k of n on ring r: `deg = −90 + 360·k/n + r·30 + dir · speed_r · t · 360` (dir = −1 for ring 1 when `same` is `alternate`), `a = deg·π/180`, `R = r_r · stageSize · 1`, `depth = sin a`, `x = cx + cos a · R`, `y = cy + depth · R · cos(0°)`, `scale = 1 + 0.16 · depth · sin(0°)`. Write `li.style.transform = translate3d(x, y, 0) scale(scale)`; when tilt > 0 also `opacity = 0.62 + 0.38 · (depth/2 + 0.5)` and `z-index = round(40 + depth·30)` (hub is 50). `upright` `tangent`: `button.style.transform = rotate(deg + 90°·dir)`; `upright`: no rotation. Update the ellipses: `rx = R`, `ry = R·cos(tilt)`, and the link `x2/y2` to the active node.
- Speed: `speed = { play, hold, tour }`, `clockTween.timeScale(play × hold × tour)` in the `onUpdate` of `gsap.to(speed, { …, ease: 'power2.inOut', overwrite: 'auto' })`. Hover/focus → `hold: 0` over 0.6s; leave/blur → `hold: 1` over 0.8s; Pause → `play: 0 | 1` over 0.7s; tour → `tour: 0.35` over 0.8s, back to 1 on first input.
- Detail swap: `gsap.fromTo([name, desc], { y: 10, opacity: 0 }, { y: 0, opacity: 1, duration: 0.5, ease: 'power3.out', stagger: 0.05 })`.
- Tour: `gsap.delayedCall(1.6, step)`, each step highlights the next of nodes [1, 5, 9, 3, 7, 11] and re-schedules itself after 2.6s.
**Interaction:** `pointerenter`/`pointerleave` on each `<li>` (static wrapper) plus `focus`/`blur`/`click` on its button. A capture-phase `pointerdown`/`keydown` on the root ends the tour. Pause toggles `aria-pressed` and its label (Pause ↔ Play). Off-screen (`ml:pause` / IntersectionObserver) the clock tween is paused and resumed.
**Responsive behavior:** A `ResizeObserver` on the stage re-measures `stageSize` and re-renders, so ring radii always follow the layout. Under 900px the grid stacks (copy, detail card, stage), chips shrink to 40px and labels show only for the active/focused node so they never collide. Periods do not change with screen size.
**Accessibility:** Every node is a `<button>` with `aria-label="Name — Category"`; tabbing through them stops the orbit and announces the detail card (`aria-live="polite"`). Focus rings are visible. The Pause button (`aria-pressed`) satisfies WCAG 2.2.2 for continuous motion. Under `prefers-reduced-motion: reduce` there is no clock tween and no tour: nodes are rendered once at t = 0, hover/focus still updates the card and connector instantly, the Pause button is disabled and reads "Static".
**Performance rules:** One tween for the whole system; `render()` only writes `transform`, `opacity` and `z-index` on 12 elements plus a few SVG attributes. `will-change: transform` on the items only. Wrap in `gsap.context()`; on teardown `ctx.revert()`, abort listeners (AbortController), disconnect the ResizeObserver and kill the tour call. Pause while off-screen.
**Customization knobs:** inner orbit 24s (rings at 1, 2/3, 2/5 speed), radius 1×, ring phase 30°, tilt 0°, orientation `upright`, ring direction `same`, accent `#d4ff3a`. Node data (name, category, icon path, description) lives in one array.
**Acceptance criteria:**
- [ ] The inner ring completes a revolution in exactly 24s; the loop never jumps when the clock repeats.
- [ ] Labels stay level in `upright` mode; chips face their direction of travel in `tangent` mode.
- [ ] Hover or focus eases every node to a stop (no snap) and shows the connector + details; leaving resumes smoothly.
- [ ] With tilt, front nodes pass over the hub and back nodes behind it.
- [ ] Pause button works; reduced motion shows a static diagram; no console errors or horizontal overflow. Source
Full source code
<div class="orb">
<section class="orb__copy">
<p class="orb__eyebrow">SVG · Loop · 083</p>
<h1 class="orb__h">One core, <em>twelve</em> integrations.</h1>
<p class="orb__p">Every service circles the platform on its own ring. Hover or focus a node and the system eases to a stop so you can read it.</p>
<div class="orb__detail" aria-live="polite">
<p class="orb__detail-k"><span class="orb__detail-ring">Ring 01</span><span class="orb__detail-sep">·</span><span class="orb__detail-cat">Identity</span></p>
<p class="orb__detail-name">Atlas core</p>
<p class="orb__detail-desc">Twelve services, three orbits. Inner rings turn fastest, the outer ring takes the long way round.</p>
</div>
</section>
<section class="orb__stage" aria-label="Integration ecosystem">
<svg class="orb__rings" aria-hidden="true">
<ellipse class="orb__ring" data-ring="0"/>
<ellipse class="orb__ring" data-ring="1"/>
<ellipse class="orb__ring" data-ring="2"/>
<line class="orb__link"/>
</svg>
<div class="orb__hub" aria-hidden="true">
<span class="orb__hub-name">Atlas</span>
<span class="orb__hub-sub">core</span>
</div>
<ul class="orb__nodes" role="list"></ul>
</section>
<div class="orb__dock" role="group" aria-label="Orbit controls">
<button class="orb__btn orb__pause" type="button" aria-pressed="false">
<svg viewBox="0 0 24 24" aria-hidden="true"><path class="orb__ico-pause" d="M9 6v12M15 6v12"/><path class="orb__ico-play" d="M8 5.5v13l11-6.5z"/></svg>
<span class="orb__btn-label">Pause</span>
</button>
<span class="orb__readout"><b class="orb__readout-n">24</b> s / inner orbit</span>
</div>
</div>
.orb { --orb-accent: var(--knob-accent, var(--accent)); --orb-node: 52px; position: relative; min-height: 100vh; display: grid; grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr); align-items: center; gap: clamp(1.5rem, 4vw, 4rem); padding: clamp(1.5rem, 4vw, 3.5rem) clamp(1.25rem, 5vw, 5rem) 6rem; background: var(--bg); color: var(--text); overflow: hidden; }
/* ---------- copy + detail ---------- */
.orb__eyebrow { font-family: var(--font-mono); font-size: 11px; letter-spacing: .14em; text-transform: uppercase; color: var(--orb-accent); }
.orb__h { margin-top: 1.2rem; font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(2.5rem, 5vw, 4.8rem); line-height: .96; letter-spacing: -0.04em; max-width: 11ch; }
.orb__h em { font-style: italic; color: var(--orb-accent); }
.orb__p { margin-top: 1.25rem; max-width: 40ch; color: var(--muted); font-size: clamp(.95rem, 1.1vw, 1.05rem); line-height: 1.55; }
.orb__detail { margin-top: clamp(1.75rem, 4vw, 3rem); padding: 1.1rem 1.25rem 1.2rem; max-width: 380px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
.orb__detail-k { display: flex; gap: .5em; font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); }
.orb__detail-ring { color: var(--orb-accent); }
.orb__detail-name { margin-top: .55rem; font-family: var(--font-display); font-size: 1.6rem; letter-spacing: -0.02em; line-height: 1.1; }
.orb__detail-desc { margin-top: .4rem; color: var(--muted); font-size: .85rem; line-height: 1.5; min-height: 2.6em; }
/* ---------- stage ---------- */
.orb__stage { position: relative; width: 100%; max-width: 680px; aspect-ratio: 1; justify-self: center; }
.orb__rings { position: absolute; inset: 0; width: 100%; height: 100%; max-width: none; overflow: visible; pointer-events: none; }
.orb__ring { fill: none; stroke: color-mix(in srgb, var(--text) 13%, transparent); stroke-width: 1; }
.orb__ring[data-ring="1"] { stroke-dasharray: 2 6; stroke: color-mix(in srgb, var(--text) 22%, transparent); }
.orb__link { stroke: var(--orb-accent); stroke-width: 1.2; stroke-dasharray: 3 4; opacity: 0; transition: opacity .35s; }
.orb__link.is-on { opacity: .9; }
.orb__hub { position: absolute; left: 50%; top: 50%; width: clamp(64px, 17%, 124px); aspect-ratio: 1; transform: translate(-50%, -50%); display: grid; place-content: center; text-align: center; border-radius: 50%; background: var(--orb-accent); color: #0A0A0A; box-shadow: 0 0 0 10px color-mix(in srgb, var(--orb-accent) 10%, transparent), 0 0 0 22px color-mix(in srgb, var(--orb-accent) 5%, transparent); z-index: 50; }
.orb__hub-name { font-family: var(--font-display); font-style: italic; font-size: clamp(1.25rem, 2.2vw, 1.6rem); letter-spacing: -0.02em; line-height: 1; }
.orb__hub-sub { margin-top: .3em; font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .16em; text-transform: uppercase; }
.orb__nodes { position: absolute; inset: 0; list-style: none; }
.orb__item { position: absolute; left: 0; top: 0; width: var(--orb-node); height: var(--orb-node); margin: calc(var(--orb-node) / -2) 0 0 calc(var(--orb-node) / -2); will-change: transform; }
.orb__node { position: relative; display: block; width: 100%; height: 100%; border-radius: 50%; outline: none; }
.orb__chip { position: absolute; inset: 0; display: grid; place-items: center; border: 1px solid color-mix(in srgb, var(--text) 16%, transparent); border-radius: 50%; background: var(--surface); color: var(--text); transition: border-color .35s, background-color .35s, color .35s, scale .5s cubic-bezier(.16, 1, .3, 1); }
.orb__chip svg { width: 44%; height: 44%; max-width: none; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
.orb__label { position: absolute; left: 50%; top: calc(100% + 8px); transform: translateX(-50%); font-family: var(--font-mono); font-size: 10px; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); white-space: nowrap; transition: color .35s; }
.orb__node:hover .orb__chip, .orb__node:focus-visible .orb__chip, .orb__item.is-active .orb__chip { border-color: var(--orb-accent); background: var(--orb-accent); color: #0A0A0A; scale: 1.14; }
.orb__node:hover .orb__label, .orb__node:focus-visible .orb__label, .orb__item.is-active .orb__label { color: var(--text); }
.orb__node:focus-visible .orb__chip { box-shadow: 0 0 0 3px var(--bg), 0 0 0 5px var(--orb-accent); }
/* ---------- dock ---------- */
.orb__dock { position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); z-index: 60; display: flex; align-items: center; gap: 2px; padding: 4px; border: 1px solid var(--line); border-radius: 99px; background: color-mix(in srgb, var(--surface) 88%, transparent); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); white-space: nowrap; }
.orb__btn { display: inline-flex; align-items: center; gap: 7px; height: 34px; padding: 0 14px; border-radius: 99px; font-family: var(--font-mono); font-size: 11px; letter-spacing: .1em; text-transform: uppercase; transition: background-color .3s; }
.orb__btn:hover { background: color-mix(in srgb, var(--text) 8%, transparent); }
.orb__btn:focus-visible { outline: 2px solid var(--orb-accent); outline-offset: 2px; }
.orb__btn[disabled] { color: var(--muted); cursor: default; }
.orb__btn svg { width: 14px; flex: none; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
.orb__ico-play { display: none; fill: currentColor; stroke: none; }
.orb__pause[aria-pressed="true"] .orb__ico-pause { display: none; }
.orb__pause[aria-pressed="true"] .orb__ico-play { display: inline; }
.orb__readout { padding: 0 14px 0 12px; border-left: 1px solid var(--line); font-family: var(--font-mono); font-size: 11px; letter-spacing: .08em; color: var(--muted); }
.orb__readout-n { color: var(--text); font-weight: 500; }
@media (max-width: 899px) {
.orb { --orb-node: 40px; grid-template-columns: 1fr; align-content: start; padding-bottom: 6.5rem; }
.orb__stage { max-width: 520px; }
.orb__detail { max-width: none; }
.orb__label { font-size: 9px; top: calc(100% + 5px); opacity: 0; transition: opacity .3s, color .3s; }
.orb__item.is-active .orb__label, .orb__node:focus-visible .orb__label { opacity: 1; }
.orb__hub-sub { font-size: 8px; }
}
@media (prefers-reduced-motion: reduce) {
.orb__chip, .orb__link { transition: none; }
}
:root[data-theme="light"] .orb__hub,
:root[data-theme="light"] .orb__node:hover .orb__chip,
:root[data-theme="light"] .orb__node:focus-visible .orb__chip,
:root[data-theme="light"] .orb__item.is-active .orb__chip { color: #F4F2EE; }
// Orbit Loop — twelve nodes on three tilted rings; one linear clock tween, trig positions, eased stop on hover/focus.
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { period: 24, radius: 1, phase: 30, tilt: 0, orientation: 'upright', direction: 'same', accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const RINGS = [
{ r: 0.2, speed: 1 }, // r: fraction of the stage size · speed: revolutions per inner revolution
{ r: 0.325, speed: 2 / 3 },
{ r: 0.45, speed: 2 / 5 },
];
const CYCLE = 15; // inner revolutions until every ring is back at its start (15 · 10 · 6 whole turns) — the seamless loop length
const ICONS = {
lock: 'M7 11V8a5 5 0 0 1 10 0v3M5 11h14v9H5z',
card: 'M3 6h18v12H3zM3 10h18M7 15h4',
search: 'M11 4a7 7 0 1 1 0 14a7 7 0 1 1 0-14M16 16l4.5 4.5',
mail: 'M3 6h18v12H3zM3 7l9 6 9-6',
db: 'M4 6c0-1.7 3.6-3 8-3s8 1.3 8 3-3.6 3-8 3-8-1.3-8-3zM4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3',
bars: 'M5 20V11M12 20V5M19 20v-6',
pin: 'M12 21s-6-5.5-6-11a6 6 0 0 1 12 0c0 5.5-6 11-6 11zM12 8.5a1.5 1.5 0 1 1 0 3a1.5 1.5 0 1 1 0-3',
doc: 'M6 3h9l4 4v14H6zM14 3v5h5M9 13h7M9 17h5',
chat: 'M4 5h16v11H9l-5 4z',
cal: 'M4 6h16v14H4zM4 10h16M8 3v5M16 3v5',
video: 'M3 6h13v12H3zM16 10l5-3v10l-5-3',
form: 'M4 5h4v4H4zM11 7h9M4 15h4v4H4zM11 17h9',
};
const NODES = [
{ ring: 0, name: 'Auth', cat: 'Identity', icon: 'lock', desc: 'Passkeys, SSO and session rotation, issued by the core on every request.' },
{ ring: 0, name: 'Payments', cat: 'Commerce', icon: 'card', desc: 'Checkout, refunds and payouts in 38 currencies, settled nightly.' },
{ ring: 0, name: 'Search', cat: 'Discovery', icon: 'search', desc: 'Typo-tolerant search over every record, re-indexed within two seconds.' },
{ ring: 1, name: 'Mail', cat: 'Messaging', icon: 'mail', desc: 'Transactional mail with templates versioned next to the code.' },
{ ring: 1, name: 'Storage', cat: 'Data', icon: 'db', desc: 'Encrypted object storage with signed, expiring links.' },
{ ring: 1, name: 'Analytics', cat: 'Insight', icon: 'bars', desc: 'Cookieless event counts, rolled up hourly and kept for 25 months.' },
{ ring: 1, name: 'Maps', cat: 'Location', icon: 'pin', desc: 'Geocoding and delivery zones drawn straight on the map.' },
{ ring: 2, name: 'CMS', cat: 'Content', icon: 'doc', desc: 'Structured content with drafts, scheduling and per-field history.' },
{ ring: 2, name: 'Chat', cat: 'Support', icon: 'chat', desc: 'In-app conversations routed to the right team by topic.' },
{ ring: 2, name: 'Calendar', cat: 'Scheduling', icon: 'cal', desc: 'Bookings that respect time zones, buffers and holidays.' },
{ ring: 2, name: 'Video', cat: 'Media', icon: 'video', desc: 'Upload once, stream adaptively, thumbnails generated on ingest.' },
{ ring: 2, name: 'Forms', cat: 'Input', icon: 'form', desc: 'Validated forms whose answers land as typed records.' },
];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches || !!window.MotionLab?.reducedMotion;
const root = document.querySelector('.orb');
const stage = root.querySelector('.orb__stage');
const list = root.querySelector('.orb__nodes');
const ringEls = [...root.querySelectorAll('.orb__ring')];
const link = root.querySelector('.orb__link');
const pauseBtn = root.querySelector('.orb__pause');
const pauseLabel = root.querySelector('.orb__btn-label');
const readN = root.querySelector('.orb__readout-n');
const detail = {
ring: root.querySelector('.orb__detail-ring'), cat: root.querySelector('.orb__detail-cat'),
name: root.querySelector('.orb__detail-name'), desc: root.querySelector('.orb__detail-desc'),
};
const INTRO = { ring: 'Ring 01–03', cat: 'Platform', name: 'Atlas core', desc: detail.desc.textContent };
// Nodes are real buttons so the ecosystem is keyboard-navigable.
const items = NODES.map((n, i) => {
const li = document.createElement('li');
li.className = 'orb__item';
li.innerHTML = `<button class="orb__node" type="button" aria-label="${n.name} — ${n.cat}"><span class="orb__chip"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="${ICONS[n.icon]}"/></svg></span><span class="orb__label" aria-hidden="true">${n.name}</span></button>`;
list.appendChild(li);
return { ...n, i, li, btn: li.firstElementChild, a: 0, x: 0, y: 0 };
});
let ctx, ac, ro, clockTween, tourCall;
let userPaused = false;
let active = null;
const clock = { t: 0 }; // revolutions of the inner ring
const speed = { play: 1, hold: 1, tour: 1 };
let geo = { cx: 0, cy: 0, S: 0 };
let cfg = {};
function measure() {
const S = stage.clientWidth;
geo = { cx: S / 2, cy: S / 2, S };
const cos = Math.cos((cfg.tilt * Math.PI) / 180);
ringEls.forEach((el, r) => {
const R = RINGS[r].r * S * cfg.radius;
el.setAttribute('cx', geo.cx); el.setAttribute('cy', geo.cy);
el.setAttribute('rx', R.toFixed(1)); el.setAttribute('ry', (R * cos).toFixed(1));
});
}
function render() {
const { cx, cy, S } = geo;
const tiltR = (cfg.tilt * Math.PI) / 180;
const cos = Math.cos(tiltR), sin = Math.sin(tiltR);
const counts = [0, 0, 0];
NODES.forEach((n) => counts[n.ring]++);
const seen = [0, 0, 0];
items.forEach((it) => {
const ring = RINGS[it.ring];
const k = seen[it.ring]++;
const dir = cfg.direction === 'alternate' && it.ring === 1 ? -1 : 1;
const deg = -90 + (360 * k) / counts[it.ring] + it.ring * cfg.phase + dir * ring.speed * clock.t * 360;
const a = (deg * Math.PI) / 180;
const R = ring.r * S * cfg.radius;
const depth = Math.sin(a); // +1 = front (bottom of the ellipse)
it.x = cx + Math.cos(a) * R;
it.y = cy + depth * R * cos;
const sc = 1 + 0.16 * depth * sin;
it.li.style.transform = `translate3d(${it.x.toFixed(2)}px, ${it.y.toFixed(2)}px, 0) scale(${sc.toFixed(3)})`;
it.li.style.opacity = sin > 0.01 ? (0.62 + 0.38 * (depth * 0.5 + 0.5)).toFixed(3) : '';
it.li.style.zIndex = String(sin > 0.01 ? Math.round(40 + depth * 30) : 40);
// upright: the label never turns · tangent: the chip follows the direction of travel
it.btn.style.transform = cfg.orientation === 'tangent' ? `rotate(${(deg + 90 * dir).toFixed(2)}deg)` : '';
});
if (active) {
link.setAttribute('x1', cx); link.setAttribute('y1', cy);
link.setAttribute('x2', active.x.toFixed(2)); link.setAttribute('y2', active.y.toFixed(2));
}
}
const applySpeed = () => clockTween?.timeScale(speed.play * speed.hold * speed.tour);
const easeSpeed = (vars, duration = 0.6) => gsap.to(speed, { ...vars, duration: reduced ? 0 : duration, ease: 'power2.inOut', overwrite: 'auto', onUpdate: applySpeed, onComplete: applySpeed });
function show(it) {
items.forEach((x) => x.li.classList.toggle('is-active', x === it));
active = it;
link.classList.toggle('is-on', !!it);
const d = it ? { ring: `Ring 0${it.ring + 1}`, cat: it.cat, name: it.name, desc: it.desc } : INTRO;
detail.ring.textContent = d.ring; detail.cat.textContent = d.cat; detail.name.textContent = d.name; detail.desc.textContent = d.desc;
if (!reduced && it) gsap.fromTo([detail.name, detail.desc], { y: 10, opacity: 0 }, { y: 0, opacity: 1, duration: 0.5, ease: 'power3.out', stagger: 0.05, overwrite: true });
render();
}
function build() {
tourCall?.kill();
ctx?.revert();
ac?.abort();
ro?.disconnect();
ac = new AbortController();
const { signal } = ac;
const accent = String(K('accent')).toLowerCase();
if (accent !== DEFAULTS.accent) root.style.setProperty('--knob-accent', accent); else root.style.removeProperty('--knob-accent');
cfg = {
period: Math.max(2, Number(K('period'))),
radius: Number(K('radius')),
phase: Number(K('phase')),
tilt: Number(K('tilt')),
orientation: K('orientation'),
direction: K('direction'),
};
readN.textContent = String(cfg.period);
Object.assign(speed, { play: userPaused ? 0 : 1, hold: 1, tour: 1 });
active = null;
ctx = gsap.context(() => {
measure();
show(null);
if (!reduced) {
// One linear clock drives every node; its timeScale is the only speed control.
clockTween = gsap.fromTo(clock, { t: 0 }, { t: CYCLE, duration: cfg.period * CYCLE, ease: 'none', repeat: -1, onUpdate: render });
applySpeed();
} else {
clockTween = null;
pauseBtn.disabled = true;
pauseLabel.textContent = 'Static';
}
// Hover / focus a node: ease the whole system to a stop and connect it to the hub.
let tour = !reduced;
const stopTour = () => { if (!tour) return; tour = false; tourCall?.kill(); easeSpeed({ tour: 1 }); };
root.addEventListener('pointerdown', stopTour, { signal, capture: true });
root.addEventListener('keydown', stopTour, { signal, capture: true });
items.forEach((it) => {
const on = () => { stopTour(); show(it); easeSpeed({ hold: 0 }, 0.6); };
const off = () => { if (active === it) show(null); easeSpeed({ hold: 1 }, 0.8); };
it.li.addEventListener('pointerenter', on, { signal });
it.li.addEventListener('pointerleave', off, { signal });
it.btn.addEventListener('focus', on, { signal });
it.btn.addEventListener('blur', off, { signal });
it.btn.addEventListener('click', on, { signal });
});
pauseBtn.addEventListener('click', () => {
userPaused = !userPaused;
pauseBtn.setAttribute('aria-pressed', String(userPaused));
pauseLabel.textContent = userPaused ? 'Play' : 'Pause';
easeSpeed({ play: userPaused ? 0 : 1 }, 0.7);
}, { signal });
// Self-demo: a slow tour that highlights nodes one by one until the first real input.
if (tour) {
const order = [1, 5, 9, 3, 7, 11];
let n = 0;
const step = () => {
if (!tour) return;
show(items[order[n++ % order.length]]);
easeSpeed({ tour: 0.35 }, 0.8);
tourCall = gsap.delayedCall(2.6, step);
};
tourCall = gsap.delayedCall(1.6, step);
}
window.addEventListener('ml:pause', () => clockTween?.pause(), { signal });
window.addEventListener('ml:resume', () => clockTween?.resume(), { signal });
}, root);
ro = new ResizeObserver(() => { measure(); render(); });
ro.observe(stage);
pauseBtn.disabled = reduced;
pauseBtn.setAttribute('aria-pressed', String(userPaused && !reduced));
if (!reduced) pauseLabel.textContent = userPaused ? 'Play' : 'Pause';
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => {
window.removeEventListener('ml:knobs', build);
tourCall?.kill();
ro?.disconnect();
ac?.abort();
ctx?.revert();
});