Looping preview · motionprompts.dev
The build prompt
From motionprompts.dev32,670 chars
# Circular Futuristic Navigation Menu — Radial Segments + Draggable Joystick ## Goal Build a fullscreen overlay navigation menu whose 6 links are arranged as **radial "pie-donut" segments** (clip-path wedges) around a **draggable white joystick** in the center. Clicking a rounded hamburger tab at the bottom of the screen toggles the overlay: the joystick **pops in with a back-out scale**, and the nav bar, footer and every wedge **flicker in with randomized glitchy yoyo blinks** (GSAP `repeat`/`yoyo` opacity pulses). While open, hovering a wedge — or **dragging the joystick toward it** — triggers a CSS "flicker to solid white" keyframe animation on that wedge, like a sci-fi console selection. Short UI sound effects play on open/close/select. The star effect is the combination of the randomized glitch-flicker reveal and the lerped joystick-drag segment highlighting. ## Tech Vanilla HTML/CSS/JS with ES module imports. Use `gsap` (npm) — **core only, no plugins**. No smooth-scroll library. Icons come from **Ionicons v7 web components**, served from your own origin: ```html <script type="module" src="/vendor/ionicons/ionicons.esm.js"></script> <script nomodule src="/vendor/ionicons/ionicons.js"></script> ``` Get those two files with `npm i [email protected]` and copy `node_modules/ionicons/dist/ionicons/` into your public directory. Copy the **whole** folder: the loader fetches its `p-*.entry.js` chunks and one `svg/<name>.svg` per icon at runtime, resolved relative to the script's own URL. Ship `index.html`, `styles.css`, and an ES-module `script.js` (`<script type="module" src="./script.js">`). ## Layout / HTML The wedge segments are generated by JS at runtime; the static skeleton is: ```html <header class="rest-head"> <!-- resting-state chrome, never animated --> <a href="#top" class="brand">Kraft <span>Interactive</span></a> <p class="rest-tag">Early-stage capital · Games & interactive tech</p> </header> <p class="rest-hint"><span class="rest-glyph" aria-hidden="true">✳</span>Menu · Pull up</p> <div class="menu-toggle-btn"> <div class="hamburger-bar"></div> <div class="hamburger-bar"></div> </div> <div class="menu-overlay"> <div class="menu-bg"></div> <div class="menu-overlay-nav"> <div class="close-btn"> <div class="close-btn-bar"></div> <div class="close-btn-bar"></div> </div> <div class="menu-overlay-items"> <a href="#"><ion-icon name="logo-google"></ion-icon></a> <a href="#"><ion-icon name="logo-github"></ion-icon></a> <a href="#"><ion-icon name="logo-vercel"></ion-icon></a> </div> </div> <div class="menu-overlay-footer"> <p>Copyright © 2025 All Rights Reserved</p> <div class="menu-overlay-items"> <a href="#">Cookie Settings</a> <a href="#">Privacy Policy</a> <a href="#">Legal Disclaimer</a> </div> </div> <div class="circular-menu"> <div class="joystick"> <ion-icon name="grid-sharp" class="center-icon center-main"></ion-icon> <ion-icon name="chevron-up-sharp" class="center-icon center-up"></ion-icon> <ion-icon name="chevron-down-sharp" class="center-icon center-down"></ion-icon> <ion-icon name="chevron-back-sharp" class="center-icon center-left"></ion-icon> <ion-icon name="chevron-forward-sharp" class="center-icon center-right"></ion-icon> </div> </div> </div> ``` The 6 menu items (label / ionicon name / href) that the JS turns into wedges: ```js const menuItems = [ { label: "Vision", icon: "scan-sharp", href: "#vision" }, { label: "Portfolio", icon: "layers-sharp", href: "#portfolio" }, { label: "People", icon: "person-sharp", href: "#people" }, { label: "Insights", icon: "browsers-sharp", href: "#insights" }, { label: "Careers", icon: "stats-chart-sharp", href: "#careers" }, { label: "About Us", icon: "reader-sharp", href: "#about" }, ]; ``` ## Styling **Global / typography** - `* { margin: 0; padding: 0; box-sizing: border-box; }` - Palette — a near-black room, bone type, a lilac glow and exactly one ember hit: ```css :root { --ink: #f5f2ec; --muted: rgba(245, 242, 236, 0.68); --lilac: #c9b8f5; /* every glow, rim and hover */ --ember: #ff5a1f; /* one accent, used sparingly */ --char: #0f0f0f; /* type on the bone surfaces */ --bone: #f5f2ec; /* the toggle dome and the joystick */ --bg: #0f0f0f; } ``` - `body { font-family: "Space Grotesk", sans-serif; background: var(--bg); }` — **Space Grotesk** for display and labels, **Space Mono** for the small uppercase copy. - `p, a { color: var(--muted); text-decoration: none; font-family: "Space Mono", monospace; font-size: 11px; letter-spacing: 0.08em; }` - **Resting-state chrome** (none of it animates; it exists so the closed page is not an empty black rectangle): `.rest-head` is a fixed top row (`display:flex; justify-content:space-between; align-items:baseline; padding:1.5rem; pointer-events:none`, with `.brand` re-enabling `pointer-events:auto`) holding the wordmark and `.rest-tag` (10px uppercase, right-aligned). `.rest-hint` is a fixed centred line at `bottom:5.4rem` reading `✳ Menu · Pull up`, where `.rest-glyph` is the lilac asterisk pulsing on a `3.2s` `glyph-pulse` keyframe — disabled under `prefers-reduced-motion`. **Bottom toggle tab** — a big white half-dome peeking up from the bottom edge: - `.menu-toggle-btn`: `position: fixed; bottom: -6rem; left: 50%; transform: translateX(-50%); width: 25rem; height: 10rem; padding-top: 2rem; border-radius: 50% 50% 0 0 / 90% 90% 0 0; background-color: var(--bone); color: var(--char); box-shadow: 0 -1px 0 var(--lilac), 0 -18px 48px rgba(201,184,245,.18); display: flex; flex-direction: column; justify-content: flex-start; align-items: center; gap: 0.25rem; cursor: pointer;` — only its top ~4rem arc is visible, and the lilac shadow makes it glow up into the dark page. - `.hamburger-bar`: `width: 2rem; height: 0.125rem; background-color: var(--char);` (two of them, stacked with the 0.25rem gap). - Under `768px` the dome must narrow to `width: min(20rem, 82vw)`: at `25rem` it is wider than a phone, its shoulders fall off both edges and the closed state reads as a flat bone band instead of a half-circle. **Overlay** - `.menu-overlay`: `position: fixed; top: 0; left: 0; width: 100vw; height: 100svh; display: flex; justify-content: center; align-items: center; overflow: hidden; z-index: 100;` and, crucially, initial state `opacity: 0; pointer-events: none;` in the CSS. - `.menu-bg`: `position: absolute; width: 100%; height: 100%;` with the backdrop image as `background: url(...) no-repeat 50% 50%; background-size: cover;`. **Overlay nav & footer** - `.menu-overlay-nav, .menu-overlay-footer`: `position: absolute; width: 100vw; padding: 1.5rem; display: flex; justify-content: space-between; align-items: center;` — nav pinned `top: 0`, footer `bottom: 0`. - `.close-btn`: `position: relative; width: 1.5rem; height: 1.5rem; cursor: pointer;` with two `.close-btn-bar`s: `position: absolute; top: 50%; width: 1.5rem; height: 0.125rem; background-color: var(--ink);` (turning `var(--lilac)` on hover), first rotated `45deg`, second `-45deg` (an X). - `.menu-overlay-items { display: flex; gap: 1rem; }`; the nav's icon links get `font-size: 18px`. **Circular menu & joystick** - `.circular-menu`: `position: relative; width: 600px; height: 600px; z-index: 10;` (JS overrides the size at runtime). **Do not add `border-radius: 50%; overflow: hidden;` here as a "round failsafe"** — it looks harmless (the wedges only reach `0.42` of the box, this clip would sit at `0.5`) but an ancestor clip makes the browser drop each wedge's own `clip-path` from its backdrop-filter layer, and the full square wedge boxes reappear, clipped only to that circle: four translucent rectangles poking out of the wheel, on desktop. - `.joystick`: `position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 100px; height: 100px; background: var(--bone); border-radius: 50%; box-shadow: 0 0 0 1px rgba(201,184,245,.55), 0 0 0 6px rgba(201,184,245,.12), 0 10px 40px rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; user-select: none; cursor: grab; touch-action: none; z-index: 100;` — the double lilac ring reads as a lit pad. `touch-action: none` is required: the pad is dragged, and without it a finger scrolls the page instead. - `.center-icon`: `position: absolute; color: var(--char); font-size: 12px;`. `.center-main` is `font-size: 30px` (centered by the flex). The four chevrons sit at the pad edges: `.center-up { top: 0.75rem; left: 50%; transform: translateX(-50%); }`, `.center-down { bottom: 0.75rem; ... }`, `.center-left { left: 0.75rem; top: 50%; transform: translateY(-50%); }`, `.center-right { right: 0.75rem; ... }` — it reads like a game-controller D-pad. **Wedge segments (JS-created)** - `.menu-segment`: `position: absolute; width: 100%; height: 100%; color: var(--ink); background: rgba(245, 242, 236, 0.05); backdrop-filter: blur(20px); cursor: pointer;` — the wedge shape itself comes from an inline `clip-path: path(...)` set by JS. - **A clipped shape with frosted glass inside it is not portable today.** The two ways of writing it each break a different engine, and neither logs anything: - **(a) `backdrop-filter` and `clip-path` on the SAME box.** The filter composites that box and the engine paints the blurred backdrop against its *border rect*, ignoring the clip. WebKit does this: on iOS the six wedges collapse into one blurred square while Chromium clips them fine. (Adding `-webkit-clip-path` does not help — the clip was never missing.) - **(b) `backdrop-filter` moved to a CHILD, `clip-path` on the parent.** This is the obvious fix and it is worse: in Chromium a `clip-path` on an ancestor opens a **backdrop root**, so the child has nothing behind it left to sample and the glass simply disappears — measured at 7.67% of the desktop frame, the disc gone, only the joint lines left. The same mechanism is why `border-radius` + `overflow: hidden` on `.circular-menu` is forbidden. - **So pick per engine, and cut on the pointer** — `@media (pointer: coarse)` and nothing else. Do **not** cut on `max-width`: it strips the glass from any desktop window narrowed under the breakpoint, which is a regression on the engine that was never broken. ```css @media (pointer: coarse) { .menu-segment { background: rgba(52, 47, 70, 0.66); -webkit-backdrop-filter: none; backdrop-filter: none; } .menu-segment:hover { animation-name: flickerHoverFlat; } } ``` No filter means no composited layer, which means the `clip-path` always applies: the shape is guaranteed on touch, which is the thing that was actually reported broken. The price is the frosted glass on phones, so **the flat plate has to earn its own reading as a disc**: pitch it well above the dark smoke (a lifted lilac-grey at ~0.66 alpha, not a near-black scrim — a dark scrim over a dark backdrop leaves six labels floating in the fog), and widen the wedge joints in JS (see `segmentGap`), because without the blur nothing else draws the six sectors. - `.segment-content`: `position: absolute; display: flex; flex-direction: column; align-items: center; justify-content: center; font-weight: 600; text-align: center;` — its `ion-icon` is `font-size: 40px; margin-bottom: 10px;` (drops to `20px` at `max-width: 1000px`). - `.label`: `text-transform: none; font-family: "Space Grotesk", sans-serif; font-size: 15px; font-weight: 600; letter-spacing: 0.02em;` **Hover flicker (pure CSS keyframes — also reused by the joystick via inline styles)** - `.menu-segment:hover { animation: flickerHover 350ms ease-in-out forwards; z-index: 10; }` - `.menu-segment:hover .segment-content { animation: contentFlickerHover 350ms ease-in-out forwards; }` - `@keyframes flickerHover` alternates the wedge between translucent-blurred and near-solid white, converging on solid: `0% { background: rgba(255,255,255,0.05); backdrop-filter: blur(20px); }` → `12% { rgba .7 / blur 8px }` → `24% { rgba .15 / blur 18px }` → `36% { rgba .85 / blur 5px }` → `48% { rgba .25 / blur 15px }` → `60% { rgba .9 / blur 3px }` → `72% { rgba .3 / blur 12px }` → `84% { rgba .95 / blur 2px }` → `100% { background: rgba(255,255,255,1); backdrop-filter: blur(0px); }`. - `@keyframes flickerHoverFlat` is the same strobe with every `backdrop-filter` step removed and `0%` starting at the flat plate. It is what runs under `(pointer: coarse)`, and the JS that writes the inline animation for joystick targeting has to pick the same name — an inline style outranks the media query, so leaving `flickerHover` there would put the blur back mid-strobe and take the wedge shape with it. - `@keyframes contentFlickerHover` flickers the icon+label between white and dark while it strobes, ending black on the now-white wedge: `0% { color: white; opacity: 1 }` → `12% { color: #333; opacity: .4 }` → `24% { white / .9 }` → `36% { #333 / .3 }` → `48% { white / .8 }` → `60% { #333 / .2 }` → `72% { white / .7 }` → `84% { #333 / .1 }` → `100% { color: #000; opacity: 1 }`. ## GSAP effect (exhaustive) GSAP core only — everything is tween-based (`gsap.to` / `gsap.set`); no ScrollTrigger, no SplitText, no timelines. State flags: `isOpen` (starts `false`) and `isMenuAnimating` (re-entry guard: `toggleMenu` returns early while `true`). ### 1) Responsive sizing + wedge geometry (runs on `DOMContentLoaded`) Compute a config from the viewport: - `isMobile = window.innerWidth < 1000` - `maxSize = Math.min(viewportWidth * 0.9, viewportHeight * 0.9)` - `menuSize = isMobile ? Math.min(maxSize, 480) : 700` - `center = menuSize / 2`, `innerRadius = menuSize * 0.08`, `outerRadius = menuSize * 0.42`, `contentRadius = menuSize * 0.28` - `segmentGap = matchMedia("(pointer: coarse)").matches ? 0.7 : 0.19` — degrees trimmed off each side of a wedge. `0.19°` is a hairline the blur is happy to draw over; on the flat coarse-pointer plate the joints would vanish, so a coarse pointer gets a joint you can actually see (~3.5px at the rim, tapering to nothing at the hub, like a segmented dial). Set `.circular-menu`'s width/height to `menuSize` px. Then for each of the 6 items build an `<a class="menu-segment" href="...">` sized `menuSize × menuSize` and clipped to an annular wedge with `clip-path: path('...')`: - `anglePerSegment = 360 / 6 = 60`; `baseStartAngle = 60 * index`; `centerAngle = baseStartAngle + 30`. - Leave a gap between wedges: `startAngle = baseStartAngle + segmentGap`, `endAngle = baseStartAngle + 60 - segmentGap`. - Angles are measured from 12 o'clock, so every trig call uses `(angle - 90) * Math.PI / 180`. Compute the 4 corner points at `innerRadius`/`outerRadius` for `startAngle`/`endAngle` (x = `center + r*cos`, y = `center + r*sin`). - Path data: `M innerStart` → `L outerStart` → `A outerRadius outerRadius 0 largeArc 1 outerEnd` → `L innerEnd` → `A innerRadius innerRadius 0 largeArc 0 innerStart` → `Z` (with `largeArc = endAngle - startAngle > 180 ? 1 : 0`, i.e. 0 here). - Inject the content at the wedge's angular bisector: a `.segment-content` absolutely positioned at `left = center + contentRadius*cos((centerAngle-90)·π/180)`, `top = center + contentRadius*sin(...)`, with inline `transform: translate(-50%, -50%)`, containing `<ion-icon name="...">` and `<div class="label">…</div>`. - Segment `pointerenter` → play the "select" sound only if `isOpen` (always `.catch(() => {})` the play promise). Use `pointerenter`, not `mouseenter`, so a finger arms the cue too. - Keep the *shape* work (size, `clip-path`, `.segment-content` `left`/`top`) in its own function that takes an existing node, and have the builder call it after creating the markup. The debounced `resize`/`orientationchange` handler then re-runs that same function over the six live wedges — new geometry, same DOM, open menu intact. Initial GSAP states: `gsap.set(joystick, { scale: 0 })` and `gsap.set([nav, footer], { opacity: 0 })`. Wire `click` on both `.menu-toggle-btn` and `.close-btn` to `toggleMenu`. ### 2) OPEN sequence (`toggleMenu` when closed) Set `isMenuAnimating = true`, `isOpen = true`, play the "open" sound, then fire these tweens in parallel: 1. **Overlay fade-in**: `gsap.to('.menu-overlay', { opacity: 1, duration: 0.3, ease: "power2.out", onStart: () => overlay.style.pointerEvents = "all" })`. 2. **Joystick pop**: `gsap.to('.joystick', { scale: 1, duration: 0.4, delay: 0.2, ease: "back.out(1.7)" })` — an overshooting spring from scale 0. 3. **Nav + footer glitch-in**: first `gsap.set([nav, footer], { opacity: 0 })`, then `gsap.to([nav, footer], { opacity: 1, duration: 0.075, delay: 0.3, repeat: 3, yoyo: true, ease: "power2.inOut", onComplete: () => gsap.set([nav, footer], { opacity: 1 }) })`. The odd repeat count + yoyo would end at opacity 0, hence the forced `set` to 1 on complete — that final snap is part of the glitch look. 4. **Segment flicker cascade, in RANDOM order**: shuffle the indices `[0..5]` (e.g. `[...Array(n).keys()].sort(() => Math.random() - 0.5)`). For each `originalIndex` at `shuffledPosition`: - `gsap.set(segment, { opacity: 0 })` - `gsap.to(segment, { opacity: 1, duration: 0.075, delay: shuffledPosition * 0.075, repeat: 3, yoyo: true, ease: "power2.inOut", onComplete: ... })` — on complete, `gsap.set(segment, { opacity: 1 })`, and if `originalIndex === 5` (the last item's index, not the last shuffled slot) set `isMenuAnimating = false`. - Net effect: each wedge blinks on/off ~4 times over 0.3s, wedges starting 75ms apart in a random sequence — a hexagonal HUD booting up. ### 3) CLOSE sequence (`toggleMenu` when open) Set `isMenuAnimating = true`, `isOpen = false`, play the "close" sound, then in parallel: 1. **Nav + footer glitch-out**: `gsap.to([nav, footer], { opacity: 0, duration: 0.05, repeat: 2, yoyo: true, ease: "power2.inOut", onComplete: () => gsap.set([nav, footer], { opacity: 0 }) })`. 2. **Joystick shrink**: `gsap.to('.joystick', { scale: 0, duration: 0.3, delay: 0.2, ease: "back.in(1.7)" })`. 3. **Segments flicker out in a fresh random order**: for each shuffled segment `gsap.to(segment, { opacity: 0, duration: 0.05, delay: shuffledPosition * 0.05, repeat: 2, yoyo: true, ease: "power2.inOut", onComplete: () => gsap.set(segment, { opacity: 0 }) })` — faster and snappier than the open (50ms blinks, 50ms stagger). 4. **Overlay fade-out, last**: `gsap.to('.menu-overlay', { opacity: 0, duration: 0.3, delay: 0.6, ease: "power2.out", onComplete: () => { overlay.style.pointerEvents = "none"; isMenuAnimating = false; } })`. ### 4) Joystick drag + lerp + segment targeting (rAF loop) A persistent `requestAnimationFrame` loop drives the joystick with **lerp factor 0.15**: - State: `isDragging`, `currentX/Y` (rendered), `targetX/Y` (goal), `activeSegment`. - Every frame: `currentX += (targetX - currentX) * 0.15` (same for Y), then `gsap.set(joystick, { x: currentX, y: currentY })`. - **pointerdown** on the joystick (ignore secondary buttons, and ignore it outright if a pointer already owns the pad — two fingers sharing one `isDragging` flag means lifting the first freezes the second): call `setPointerCapture(e.pointerId)`, record the pad's center from `getBoundingClientRect()`, then on document `pointermove` — filtered to that same `pointerId` — compute `deltaX/deltaY` from that center and `distance = √(dx²+dy²)`: - dead zone: `distance <= 20` → `targetX = targetY = 0`; - clamp: max drag radius is `25px` (`100 * 0.25`); if `distance > 25`, scale the delta by `25 / distance`; - otherwise target the raw delta. Call `e.preventDefault()` when the event is `cancelable`. - **pointerup / pointercancel**: stop dragging, reset targets to `0,0` — the knob **springs back to center via the lerp** (no tween). `pointercancel` matters on touch: the browser can steal the gesture and no `pointerup` ever arrives, which would leave the knob stuck off-centre forever. - **Segment targeting** (inside the rAF loop): while dragging and `√(currentX² + currentY²) > 20`, compute `angle = atan2(currentY, currentX) * 180/π` and `segmentIndex = floor(((angle + 90 + 360) % 360) / 60) % 6` (the `+90` maps atan2's 3-o'clock zero to the menu's 12-o'clock start). If that segment differs from `activeSegment`: clear the previous one's inline `animation` (on both the wedge and its `.segment-content`) and `z-index`, then set on the new one — inline — `animation: "<flicker> 350ms ease-in-out forwards"` on the wedge, where `<flicker>` is `flickerHover` on a fine pointer and `flickerHoverFlat` when `matchMedia("(pointer: coarse)")` matches (an inline style outranks the media query, so the JS has to make that choice itself), `animation: "contentFlickerHover 350ms ease-in-out forwards"` on its content, `z-index: 10`, and play the "select" sound (if `isOpen`). When the knob returns inside the dead zone (or drag ends), clear the active segment's inline animation styles so it reverts to translucent. ## Assets / images - **1 background image, landscape 16:9 (~1440×810 or larger)** — a full-bleed backdrop for the open overlay: a bold, glossy, high-contrast hero visual on a black/dark background (any striking centered subject works). It sits behind the translucent blurred wedges, so it should have strong color/contrast for the `backdrop-filter` to read. - **3 short UI sound effects (mp3, < 1s each)**: an "open" whoosh/click, a "close" variant, and a "select" blip for segment highlighting. Treat them as optional: create them or skip them, but **always wrap `Audio.play()` in `.catch(() => {})`** (autoplay policies and missing files must never throw console errors). ## Behavior notes - The overlay is non-interactive (`pointer-events: none`) until opened; the toggle tab stays visible beneath it (overlay has `z-index: 100`). - Menu size is responsive: 700px desktop, `min(90vmin, 480px)` under 1000px viewport width; segment icons shrink from 40px to 20px at the same breakpoint. Recompute it on a debounced `resize`/`orientationchange` (~150ms) and re-apply the size + `clip-path` **over the existing nodes**, never by rebuilding the DOM — a phone that rotates would otherwise keep the portrait geometry and spill the wheel out of the short side, and a rebuild would kill an open menu mid-animation. **Gate that handler on a change of `innerWidth`**: a mobile browser fires `resize` every time the URL bar slides in or out, `innerHeight` feeds `maxSize`, and the wheel's diameter would jump under the user's finger for nothing. - The drag interaction uses **pointer events** (`pointerdown` on the pad, `pointermove`/`pointerup`/`pointercancel` on the document, with `setPointerCapture`), never `mousedown` — a finger has to be able to drive the joystick, which is half of the component. Match the same pointer grammar for the per-wedge audio cue (`pointerenter`, not `mouseenter`). Hover flicker still works as a fallback everywhere. - Every `Audio.play()` goes through one helper that swallows the rejection (`play()` returns a promise that rejects under autoplay policy, on a muted device or on a missing file) and that also survives `new Audio()` throwing. Sound is decoration here; it must never break the open/close sequence. - The `isMenuAnimating` flag must block re-toggling until the open/close choreography finishes. - No infinite loops besides the rAF lerp; no scroll behavior at all — the component is a self-contained fixed-position widget over a black page. ## Images This component ships with 4 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios. ``` https://motionprompts.dev/c/bitkraft-menu/menu.jpg https://motionprompts.dev/c/bitkraft-menu/menu-open.mp3 https://motionprompts.dev/c/bitkraft-menu/menu-close.mp3 https://motionprompts.dev/c/bitkraft-menu/menu-select.mp3 ``` Three of the four are **sound**, and they are not decoration: the component plays one on open, one on close, and one on every link hover. Wire them in the order above. Browsers gate audio until the user has interacted with the page, so the very first sound may be swallowed — that is the browser, not a bug in the component, and clicking the menu button counts as the interaction that unlocks the rest. They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution. ## Using this outside its demo page This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first. - **Palette on `:root`** — `--ink`, `--muted`, `--lilac`, `--ember`, `--char`, `--bone`, `--bg`. These names are not namespaced and they collide: `--ink` is defined by 164 of the 219 components in this catalogue, `--paper` by 94, `--muted` by 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (`.my-section { --ink: … }`) or rename them with a prefix. - **Rules on `*`, `body`** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it. - **Full-screen overlay** — a fixed element covers the viewport (a loader or transition). Only one may exist per page and it must remove itself when done. If your page already has one, keep that and drop this; otherwise the second silently hides the first. ## Adapting this to React Everything above describes a standalone module that waits for `DOMContentLoaded`, builds six wedge elements from `menuItems` and appends them into `.circular-menu`, wires `.menu-toggle-btn`/`.close-btn` to `toggleMenu()`, and starts one `requestAnimationFrame` chain — `initCenterDrag`'s `animate()` — that runs for the rest of the page's life once called. All of it leans on module-level `let isOpen`, `let isMenuAnimating`, and `let responsiveConfig`, which exist because a plain script has nowhere else to put per-widget state; a script never expects a second copy of itself to run against the same page. React withdraws that assumption. Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen, and the double-invoke does **not** remount the DOM subtree in between — `.circular-menu` and `.joystick` are the same nodes on both passes. The concrete damage here: the six `createSegment()` calls run again and `menu.appendChild(segment)` a second set of six wedges onto the same `.circular-menu`, so `document.querySelectorAll(".menu-segment")` returns twelve overlapping, identically-clipped elements instead of six, and `toggleMenu`'s shuffle-and-flicker cascade now animates all twelve, doubling the boot sequence and hiding half the wedges behind their own duplicates. `initCenterDrag()` runs again too, and since its `mousedown` handler and its `animate` loop are both freshly-created closures on every call — unlike `toggleMenu`, which is a single stable top-level function reference — the second call attaches a second `mousedown` listener to the same joystick and kicks off a second, independent, never-cancelled `animate()` chain: two loops writing `gsap.set(joystick, { x, y })` every frame, forever, for a widget that only needed one. None of this reproduces in a production build, because only development double-invokes effects. Treat the cleanup as part of the effect. *(1) The entry point* — The script listens for `DOMContentLoaded` with no `readyState` check first. By the time a React component mounts, that event has already fired, so the listener body — `getResponsiveConfig()`, sizing `.circular-menu`, the six `createSegment` calls, wiring the toggle/close buttons, `initCenterDrag()` — never runs. Delete the listener and move its body into a `useEffect` with an empty dependency array. *(2) Element lookups* — `.circular-menu`, `.joystick`, `.menu-overlay-nav`, `.menu-overlay-footer`, `.menu-toggle-btn`, `.close-btn`, `.menu-overlay`, and the `.menu-segment` list are all reached with unscoped `document.querySelector`/`querySelectorAll`. Give the component a root ref and scope every one of them off it. The `.menu-segment` list is worth more than a rename: `toggleMenu` re-queries it once per call, and the drag-targeting logic inside `animate()` re-queries it on every frame it has an active drag, purely to read one entry by index. Build the array once, right after the six `createSegment` calls, and hand that same reference to both `toggleMenu` and `animate` instead of re-querying the DOM up to sixty times a second. *(3) Cleanup* — `toggleMenu`'s tweens are not created during the effect's synchronous setup; they run later, from the `click` listeners on `.menu-toggle-btn`/`.close-btn`. A `gsap.context` wrapping only the initial `gsap.set(joystick, { scale: 0 })` / `gsap.set([nav, footer], { opacity: 0 })` auto-tracks none of the open/close tweens, so `ctx.revert()` on its own would leave an in-flight sequence running untouched. Split the two branches of `toggleMenu` into named context methods instead: ```jsx const ctx = gsap.context((self) => { gsap.set(joystick, { scale: 0 }); gsap.set([navEl, footerEl], { opacity: 0 }); self.add("openMenu", () => { /* overlay fade-in, joystick pop, nav/footer and segment flicker cascade */ }); self.add("closeMenu", () => { /* the mirrored teardown sequence */ }); }, rootRef); function handleToggle() { isOpenRef.current ? ctx.closeMenu() : ctx.openMenu(); } toggleBtn.addEventListener("click", handleToggle); closeBtn.addEventListener("click", ctx.closeMenu); ``` `ctx.revert()` then kills whichever tweens are in flight and clears the inline `opacity`/`scale` GSAP wrote. Notice what porting to this shape costs you: the original script's `toggleMenu` is a single stable top-level function, so the DOM's own listener-dedup rule quietly protects `.menu-toggle-btn` from ever getting a second `click` listener across the StrictMode remount. `handleToggle` above is a fresh closure created inside the effect, so that protection is gone — remove it explicitly in the cleanup (`toggleBtn.removeEventListener("click", handleToggle)`, same for `closeBtn`), or you reintroduce exactly the doubled-listener class of bug this section opened with. The per-segment `mouseenter` listeners don't need separate removal: remove the six appended segment nodes from `.circular-menu` in the same cleanup (or keep them in a ref and call `.remove()` on each), and their listeners go with them — this is also what stops the wedge count from doubling on the next mount. Finally, `initCenterDrag`'s `animate()` chain needs the standard rAF treatment, with one wrinkle: it reschedules itself every frame, so the id `cancelAnimationFrame` needs is whatever the most recent `requestAnimationFrame(animate)` call inside the loop produced, not the one the initial call returned — keep it in a ref you overwrite every frame. The joystick's `mousedown` handler installs two more listeners, on `document`, for `mousemove` and `mouseup`; those are ordinarily removed by `endDrag`, but a StrictMode unmount that lands mid-drag — mouse down, not yet up — skips `endDrag` entirely and leaves both stuck on `document`, closing over `centerX`/`centerY` from a joystick instance that no longer exists. Give `drag`/`endDrag` names the effect's cleanup can reach, and remove both unconditionally there, not only through the normal end-of-drag path. ### `isOpen`, `isMenuAnimating`, `responsiveConfig` are module state, not component state All three are `let` bindings at module scope — the only way a plain script gets state that survives between calls without an object to hang it on. A component that can mount more than once — two of these menus on the same page, or one remounted after a route change — has every instance reading and writing the same three variables: one instance's `toggleMenu` can leave `isMenuAnimating` stuck at `true` and block a second instance's guard from ever opening, or leave `isOpen` disagreeing with what that second instance's own `gsap.set(joystick, { scale: 0 })` just wrote at mount. Move all three into refs created inside the component — `isOpenRef`, `isAnimatingRef`, `configRef` — so this widget's state stops being shared with every other instance of itself.





