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:
<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 ionicons@7.1.0 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:
<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:
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-headis a fixed top row (display:flex; justify-content:space-between; align-items:baseline; padding:1.5rem; pointer-events:none, with.brandre-enablingpointer-events:auto) holding the wordmark and.rest-tag(10px uppercase, right-aligned)..rest-hintis a fixed centred line atbottom:5.4remreading✳ Menu · Pull up, where.rest-glyphis the lilac asterisk pulsing on a3.2sglyph-pulsekeyframe — disabled underprefers-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
768pxthe dome must narrow towidth: min(20rem, 82vw): at25remit 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 stateopacity: 0; pointer-events: none;in the CSS..menu-bg:position: absolute; width: 100%; height: 100%;with the backdrop image asbackground: 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 pinnedtop: 0, footerbottom: 0..close-btn:position: relative; width: 1.5rem; height: 1.5rem; cursor: pointer;with two.close-btn-bars:position: absolute; top: 50%; width: 1.5rem; height: 0.125rem; background-color: var(--ink);(turningvar(--lilac)on hover), first rotated45deg, second-45deg(an X)..menu-overlay-items { display: flex; gap: 1rem; }; the nav's icon links getfont-size: 18px.
Circular menu & joystick
.circular-menu:position: relative; width: 600px; height: 600px; z-index: 10;(JS overrides the size at runtime). Do not addborder-radius: 50%; overflow: hidden;here as a "round failsafe" — it looks harmless (the wedges only reach0.42of the box, this clip would sit at0.5) but an ancestor clip makes the browser drop each wedge's ownclip-pathfrom 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: noneis 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-mainisfont-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 inlineclip-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-filterandclip-pathon 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-pathdoes not help — the clip was never missing.) - (b)
backdrop-filtermoved to a CHILD,clip-pathon the parent. This is the obvious fix and it is worse: in Chromium aclip-pathon 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 whyborder-radius+overflow: hiddenon.circular-menuis forbidden. - So pick per engine, and cut on the pointer —
@media (pointer: coarse)and nothing else. Do not cut onmax-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;— itsion-iconisfont-size: 40px; margin-bottom: 10px;(drops to20pxatmax-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 flickerHoveralternates 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 flickerHoverFlatis the same strobe with everybackdrop-filterstep removed and0%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 leavingflickerHoverthere would put the blur back mid-strobe and take the wedge shape with it.@keyframes contentFlickerHoverflickers 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 < 1000maxSize = Math.min(viewportWidth * 0.9, viewportHeight * 0.9)menuSize = isMobile ? Math.min(maxSize, 480) : 700center = menuSize / 2,innerRadius = menuSize * 0.08,outerRadius = menuSize * 0.42,contentRadius = menuSize * 0.28segmentGap = 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 atinnerRadius/outerRadiusforstartAngle/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(withlargeArc = endAngle - startAngle > 180 ? 1 : 0, i.e. 0 here). - Inject the content at the wedge's angular bisector: a
.segment-contentabsolutely positioned atleft = center + contentRadius*cos((centerAngle-90)·π/180),top = center + contentRadius*sin(...), with inlinetransform: translate(-50%, -50%), containing<ion-icon name="...">and<div class="label">…</div>. - Segment
pointerenter→ play the "select" sound only ifisOpen(always.catch(() => {})the play promise). Usepointerenter, notmouseenter, so a finger arms the cue too. - Keep the *shape* work (size,
clip-path,.segment-contentleft/top) in its own function that takes an existing node, and have the builder call it after creating the markup. The debouncedresize/orientationchangehandler 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:
- Overlay fade-in:
gsap.to('.menu-overlay', { opacity: 1, duration: 0.3, ease: "power2.out", onStart: () => overlay.style.pointerEvents = "all" }). - Joystick pop:
gsap.to('.joystick', { scale: 1, duration: 0.4, delay: 0.2, ease: "back.out(1.7)" })— an overshooting spring from scale 0. - Nav + footer glitch-in: first
gsap.set([nav, footer], { opacity: 0 }), thengsap.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 forcedsetto 1 on complete — that final snap is part of the glitch look. - Segment flicker cascade, in RANDOM order: shuffle the indices
[0..5](e.g.[...Array(n).keys()].sort(() => Math.random() - 0.5)). For eachoriginalIndexatshuffledPosition: 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 iforiginalIndex === 5(the last item's index, not the last shuffled slot) setisMenuAnimating = 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:
- 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 }) }). - Joystick shrink:
gsap.to('.joystick', { scale: 0, duration: 0.3, delay: 0.2, ease: "back.in(1.7)" }). - 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). - 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), thengsap.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
isDraggingflag means lifting the first freezes the second): callsetPointerCapture(e.pointerId), record the pad's center fromgetBoundingClientRect(), then on documentpointermove— filtered to that samepointerId— computedeltaX/deltaYfrom that center anddistance = √(dx²+dy²): - dead zone:
distance <= 20→targetX = targetY = 0; - clamp: max drag radius is
25px(100 * 0.25); ifdistance > 25, scale the delta by25 / distance; - otherwise target the raw delta. Call
e.preventDefault()when the event iscancelable. - pointerup / pointercancel: stop dragging, reset targets to
0,0— the knob springs back to center via the lerp (no tween).pointercancelmatters on touch: the browser can steal the gesture and nopointerupever arrives, which would leave the knob stuck off-centre forever. - Segment targeting (inside the rAF loop): while dragging and
√(currentX² + currentY²) > 20, computeangle = atan2(currentY, currentX) * 180/πandsegmentIndex = floor(((angle + 90 + 360) % 360) / 60) % 6(the+90maps atan2's 3-o'clock zero to the menu's 12-o'clock start). If that segment differs fromactiveSegment: clear the previous one's inlineanimation(on both the wedge and its.segment-content) andz-index, then set on the new one — inline —animation: "<flicker> 350ms ease-in-out forwards"on the wedge, where<flicker>isflickerHoveron a fine pointer andflickerHoverFlatwhenmatchMedia("(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 (ifisOpen). 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-filterto 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 hasz-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 debouncedresize/orientationchange(~150ms) and re-apply the size +clip-pathover 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 ofinnerWidth: a mobile browser firesresizeevery time the URL bar slides in or out,innerHeightfeedsmaxSize, and the wheel's diameter would jump under the user's finger for nothing. - The drag interaction uses pointer events (
pointerdownon the pad,pointermove/pointerup/pointercancelon the document, withsetPointerCapture), nevermousedown— 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, notmouseenter). 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 survivesnew Audio()throwing. Sound is decoration here; it must never break the open/close sequence. - The
isMenuAnimatingflag 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:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 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:
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.