All components

Circular Futuristic Navigation Menu

GSAP animation component · Published 2026-07-21 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A fullscreen overlay menu whose links are arranged as radial clip-path segments around a draggable center joystick. Clicking the bottom hamburger button toggles the overlay with GSAP opacity, scale (back.out/back.in) and randomized yoyo-flicker reveals; dragging the joystick with lerped follow motion highlights the segment it points toward via CSS flicker keyframes, with audio feedback on open/close/select.

How it's built

Categorymenu
Techgsap
Complexitysection
Performance costlight
Mobile-safedesktop-first

menu circular radial joystick drag glitch flicker gsap futuristic

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

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 &middot; Games &amp; interactive tech</p>
</header>

<p class="rest-hint"><span class="rest-glyph" aria-hidden="true">✳</span>Menu &middot; 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 &copy; 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-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-bars: 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 innerStartL outerStartA outerRadius outerRadius 0 largeArc 1 outerEndL innerEndA innerRadius innerRadius 0 largeArc 0 innerStartZ (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:
  5. gsap.set(segment, { opacity: 0 })
  6. 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.
  7. 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 <= 20targetX = 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:

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.