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 loaded via CDN script tags in the HTML:

<script type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script>
<script nomodule src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"></script>

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:

<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; }
  • body { font-family: "Host Grotesk"; background: #000; } — import Host Grotesk and IBM Plex Mono from Google Fonts.
  • p, a { color: #fff; text-decoration: none; text-transform: uppercase; font-family: "IBM Plex Mono"; font-size: 11px; }

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: #fff; color: #000; display: flex; flex-direction: column; justify-content: flex-start; align-items: center; gap: 0.25rem; cursor: pointer; — only its top ~4rem arc is visible.
  • .hamburger-bar: width: 2rem; height: 0.125rem; background-color: #000; (two of them, stacked with the 0.25rem gap).

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: #fff;, 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).
  • .joystick: position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 100px; height: 100px; background: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; user-select: none; cursor: grab; z-index: 100;
  • .center-icon: position: absolute; color: #000; 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: #fff; background: rgba(255, 255, 255, 0.05); backdrop-filter: blur(20px); cursor: pointer; — the wedge shape itself comes from an inline clip-path: path(...) set by JS.
  • .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: "Host Grotesk"; font-size: 15px; font-weight: 500;

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 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

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 hairline gap between wedges: startAngle = baseStartAngle + 0.19, endAngle = baseStartAngle + 60 - 0.19.
  • 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 mouseenter → play the "select" sound only if isOpen (always .catch(() => {}) the play promise).

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 }).
  • mousedown on the joystick: record its center from getBoundingClientRect(), then on document mousemove 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().
  • mouseup: stop dragging, reset targets to 0,0 — the knob springs back to center via the lerp (no tween).
  • 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: "flickerHover 350ms ease-in-out forwards" on the wedge, 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. Sizing is computed once on load (no resize listener needed).
  • The drag interaction is mouse-only (desktop-oriented); hover flicker still works as a fallback everywhere.
  • 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.