Instantly share code, notes, and snippets.
Created
May 17, 2026 11:09
-
Star
1
(1)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save EncodeTheCode/f0598eefcabe749cfa62e0bd5704b8e6 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"/> | |
| <title>True 3D Globe Orbit Menu</title> | |
| <style> | |
| :root{ | |
| --bg0:#020408; | |
| --bg1:#071019; | |
| --perspective:1900px; | |
| --orbitX:360; | |
| --orbitY:90; | |
| --orbitZ:240; | |
| --itemW:230px; | |
| --itemH:58px; | |
| --dividerW:120px; | |
| --dividerH:32px; | |
| --spinSpeed:12; | |
| } | |
| *{ | |
| box-sizing:border-box; | |
| } | |
| html,body{ | |
| width:100%; | |
| height:100%; | |
| margin:0; | |
| overflow:hidden; | |
| background: | |
| radial-gradient(circle at 50% 20%, rgba(80,120,255,.18), transparent 30%), | |
| radial-gradient(circle at 50% 80%, rgba(120,180,255,.10), transparent 40%), | |
| linear-gradient(180deg,var(--bg0),var(--bg1)); | |
| font-family:Segoe UI,Arial,sans-serif; | |
| color:white; | |
| } | |
| body{ | |
| perspective:var(--perspective); | |
| perspective-origin:center center; | |
| } | |
| .scene{ | |
| position:relative; | |
| width:100%; | |
| height:100%; | |
| transform-style:preserve-3d; | |
| overflow:hidden; | |
| } | |
| .orbit-root{ | |
| position:absolute; | |
| left:50%; | |
| top:50%; | |
| width:0; | |
| height:0; | |
| transform-style:preserve-3d; | |
| transform: | |
| rotateX(18deg); | |
| } | |
| .node{ | |
| position:absolute; | |
| left:0; | |
| top:0; | |
| transform-style:preserve-3d; | |
| transform-origin:center center; | |
| will-change:transform; | |
| } | |
| .face{ | |
| position:absolute; | |
| left:50%; | |
| top:50%; | |
| display:flex; | |
| align-items:center; | |
| justify-content:center; | |
| transform-style:preserve-3d; | |
| transform-origin:center center; | |
| backface-visibility:hidden; | |
| white-space:nowrap; | |
| } | |
| .label{ | |
| width:var(--itemW); | |
| height:var(--itemH); | |
| margin-left:calc(var(--itemW) / -2); | |
| margin-top:calc(var(--itemH) / -2); | |
| border-radius:18px; | |
| border:1px solid rgba(255,255,255,.12); | |
| background: | |
| linear-gradient(180deg, | |
| rgba(255,255,255,.14), | |
| rgba(255,255,255,.04) | |
| ), | |
| linear-gradient(90deg, | |
| rgba(20,30,48,.95), | |
| rgba(8,12,22,.82) | |
| ); | |
| box-shadow: | |
| 0 22px 40px rgba(0,0,0,.35), | |
| inset 0 1px 0 rgba(255,255,255,.08); | |
| font-weight:800; | |
| font-size:20px; | |
| letter-spacing:.08em; | |
| text-transform:uppercase; | |
| color:white; | |
| } | |
| .divider{ | |
| width:var(--dividerW); | |
| height:var(--dividerH); | |
| margin-left:calc(var(--dividerW) / -2); | |
| margin-top:calc(var(--dividerH) / -2); | |
| border-radius:999px; | |
| border:1px solid rgba(255,255,255,.08); | |
| background: | |
| linear-gradient(180deg, | |
| rgba(255,255,255,.08), | |
| rgba(255,255,255,.03) | |
| ); | |
| font-size:18px; | |
| font-weight:900; | |
| letter-spacing:.30em; | |
| } | |
| .panel{ | |
| position:absolute; | |
| left:50%; | |
| bottom:8vh; | |
| transform:translateX(-50%); | |
| width:min(760px,84vw); | |
| padding:22px; | |
| border-radius:24px; | |
| background: | |
| linear-gradient(180deg, | |
| rgba(8,12,18,.74), | |
| rgba(8,12,18,.48) | |
| ); | |
| border:1px solid rgba(255,255,255,.10); | |
| backdrop-filter:blur(8px); | |
| box-shadow: | |
| 0 24px 48px rgba(0,0,0,.4), | |
| inset 0 1px 0 rgba(255,255,255,.04); | |
| text-align:center; | |
| } | |
| .current{ | |
| font-size:clamp(28px,4vw,46px); | |
| font-weight:900; | |
| letter-spacing:.12em; | |
| text-transform:uppercase; | |
| } | |
| .sub{ | |
| margin-top:14px; | |
| display:flex; | |
| justify-content:center; | |
| flex-wrap:wrap; | |
| gap:12px; | |
| color:rgba(255,255,255,.70); | |
| font-size:12px; | |
| letter-spacing:.10em; | |
| text-transform:uppercase; | |
| } | |
| .sub span.active{ | |
| color:white; | |
| } | |
| .hud{ | |
| position:absolute; | |
| left:28px; | |
| top:28px; | |
| font-size:12px; | |
| line-height:1.7; | |
| letter-spacing:.12em; | |
| text-transform:uppercase; | |
| color:rgba(255,255,255,.55); | |
| user-select:none; | |
| } | |
| .scan{ | |
| position:absolute; | |
| inset:0; | |
| pointer-events:none; | |
| background: | |
| linear-gradient( | |
| to bottom, | |
| rgba(255,255,255,.025) 1px, | |
| transparent 1px | |
| ); | |
| background-size:100% 4px; | |
| opacity:.16; | |
| } | |
| @media(max-width:760px){ | |
| :root{ | |
| --orbitX:250; | |
| --orbitY:58; | |
| --orbitZ:150; | |
| --itemW:160px; | |
| --itemH:44px; | |
| } | |
| .label{ | |
| font-size:15px; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="scene"> | |
| <div class="scan"></div> | |
| <div class="hud"> | |
| LEFT / RIGHT = MENU<br> | |
| UP / DOWN = SUB ITEMS | |
| </div> | |
| <div class="orbit-root" id="orbit"></div> | |
| <div class="panel"> | |
| <div class="current" id="currentLabel"></div> | |
| <div class="sub" id="sublist"></div> | |
| </div> | |
| </div> | |
| <script> | |
| const menuItems = [ | |
| { | |
| title:"Games", | |
| items:["Start","Load","Options","Credits"] | |
| }, | |
| { | |
| title:"Videos", | |
| items:["Trailer One","Trailer Two","Scene Select","Extras"] | |
| }, | |
| { | |
| title:"Reviews", | |
| items:["Latest","Top Rated","Editors","Archive"] | |
| } | |
| ]; | |
| const orbit = document.getElementById("orbit"); | |
| const currentLabel = document.getElementById("currentLabel"); | |
| const sublist = document.getElementById("sublist"); | |
| const state = { | |
| activeIndex:0, | |
| subIndex:0, | |
| phase:0, | |
| targetPhase:0, | |
| autoSpin:0, | |
| last:performance.now(), | |
| orbitX:360, | |
| orbitY:90, | |
| orbitZ:240, | |
| nodes:[] | |
| }; | |
| function cssNum(name){ | |
| return parseFloat( | |
| getComputedStyle(document.documentElement) | |
| .getPropertyValue(name) | |
| ); | |
| } | |
| function readConfig(){ | |
| state.orbitX = cssNum("--orbitX"); | |
| state.orbitY = cssNum("--orbitY"); | |
| state.orbitZ = cssNum("--orbitZ"); | |
| } | |
| function createNode(type,index){ | |
| const node = document.createElement("div"); | |
| node.className = "node"; | |
| const face = document.createElement("div"); | |
| if(type === "label"){ | |
| face.className = "face label"; | |
| face.textContent = menuItems[index].title; | |
| }else{ | |
| face.className = "face divider"; | |
| face.textContent = "✦"; | |
| } | |
| node.appendChild(face); | |
| orbit.appendChild(node); | |
| state.nodes.push({ | |
| node, | |
| face, | |
| type, | |
| index | |
| }); | |
| } | |
| for(let i=0;i<menuItems.length;i++){ | |
| createNode("label",i); | |
| createNode("divider",i); | |
| } | |
| function updatePanel(){ | |
| const current = menuItems[state.activeIndex]; | |
| currentLabel.textContent = current.title; | |
| sublist.innerHTML = ""; | |
| current.items.forEach((item,i)=>{ | |
| const span = document.createElement("span"); | |
| span.textContent = item; | |
| if(i === state.subIndex){ | |
| span.classList.add("active"); | |
| } | |
| sublist.appendChild(span); | |
| }); | |
| } | |
| /* | |
| TRUE GLOBE ORBIT: | |
| - Items travel around a 3D elliptical globe path | |
| - Nodes themselves rotate in 3D | |
| - Faces counter-rotate to remain perfectly flat | |
| - No weird bending/distortion | |
| - Flow remains fully 3D | |
| */ | |
| function render(){ | |
| const total = state.nodes.length; | |
| const step = (Math.PI * 2) / total; | |
| for(let i=0;i<total;i++){ | |
| const item = state.nodes[i]; | |
| const a = (i * step) + state.phase; | |
| /* | |
| Globe orbit position | |
| */ | |
| const x = | |
| Math.sin(a) * state.orbitX; | |
| const y = | |
| Math.sin(a * 0.92) * state.orbitY; | |
| const z = | |
| Math.cos(a) * state.orbitZ; | |
| /* | |
| Globe tangent rotations | |
| */ | |
| const yaw = | |
| -Math.sin(a) * 68; | |
| const pitch = | |
| Math.cos(a * 0.92) * 14; | |
| /* | |
| Orbit node transforms through 3D space | |
| */ | |
| item.node.style.transform = | |
| ` | |
| translate3d(${x}px, ${y}px, ${z}px) | |
| rotateY(${yaw}deg) | |
| rotateX(${pitch}deg) | |
| `; | |
| /* | |
| Counter rotation keeps panels flat. | |
| THIS is the critical fix. | |
| The panels stay perfectly flat | |
| while still following the globe orbit. | |
| */ | |
| item.face.style.transform = | |
| ` | |
| rotateY(${-yaw}deg) | |
| rotateX(${-pitch}deg) | |
| `; | |
| /* | |
| Perspective depth | |
| */ | |
| const depth = | |
| (z + state.orbitZ) / | |
| (state.orbitZ * 2); | |
| const scale = | |
| 0.68 + (depth * 0.38); | |
| item.node.style.scale = scale; | |
| item.node.style.opacity = | |
| 0.25 + (depth * 0.78); | |
| item.node.style.zIndex = | |
| Math.floor(depth * 1000); | |
| } | |
| } | |
| function animate(now){ | |
| const dt = Math.min( | |
| 0.033, | |
| (now - state.last) / 1000 | |
| ); | |
| state.last = now; | |
| state.autoSpin -= | |
| dt * (cssNum("--spinSpeed") * 0.13); | |
| const target = | |
| state.targetPhase + state.autoSpin; | |
| state.phase += | |
| (target - state.phase) * 0.08; | |
| render(); | |
| requestAnimationFrame(animate); | |
| } | |
| function moveMenu(dir){ | |
| state.activeIndex = | |
| (state.activeIndex + dir + menuItems.length) | |
| % menuItems.length; | |
| state.subIndex = 0; | |
| const spacing = | |
| (Math.PI * 2) / menuItems.length; | |
| state.targetPhase -= dir * spacing; | |
| updatePanel(); | |
| } | |
| function moveSub(dir){ | |
| const items = | |
| menuItems[state.activeIndex].items; | |
| state.subIndex = | |
| (state.subIndex + dir + items.length) | |
| % items.length; | |
| updatePanel(); | |
| } | |
| window.addEventListener("keydown",(e)=>{ | |
| if(e.key === "ArrowLeft"){ | |
| e.preventDefault(); | |
| moveMenu(-1); | |
| } | |
| if(e.key === "ArrowRight"){ | |
| e.preventDefault(); | |
| moveMenu(1); | |
| } | |
| if(e.key === "ArrowUp"){ | |
| e.preventDefault(); | |
| moveSub(-1); | |
| } | |
| if(e.key === "ArrowDown"){ | |
| e.preventDefault(); | |
| moveSub(1); | |
| } | |
| }); | |
| window.addEventListener("resize",()=>{ | |
| readConfig(); | |
| render(); | |
| }); | |
| readConfig(); | |
| updatePanel(); | |
| render(); | |
| requestAnimationFrame(animate); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment