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 © 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 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: #fff;, 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)..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-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: #fff; background: rgba(255, 255, 255, 0.05); backdrop-filter: blur(20px); cursor: pointer;— the wedge shape itself comes from an inlineclip-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;— itsion-iconisfont-size: 40px; margin-bottom: 10px;(drops to20pxatmax-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 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 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.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 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
mouseenter→ play the "select" sound only ifisOpen(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:
- 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 }). - mousedown on the joystick: record its center from
getBoundingClientRect(), then on documentmousemovecomputedeltaX/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(). - 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, 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: "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 (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. 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
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.