Instantly share code, notes, and snippets.
Created
May 17, 2026 11:05
-
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/012e62ca76cd0641a69d2e41a5fc6037 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>Demo One Orbit Menu</title> | |
| <style> | |
| :root { | |
| --bg0: #020408; | |
| --bg1: #05070b; | |
| --text: #ecf3ff; | |
| --muted: rgba(236, 243, 255, 0.64); | |
| --perspective: 1500px; | |
| --orbit-x: 350px; | |
| --orbit-y: 60px; | |
| --orbit-z: 190px; | |
| --label-w: 210px; | |
| --label-h: 58px; | |
| --divider-w: 130px; | |
| --divider-h: 34px; | |
| --spin-speed: 14; | |
| --stage-tilt: 17deg; | |
| --panel-w: min(720px, 84vw); | |
| } | |
| * { box-sizing: border-box; } | |
| html, body { | |
| width: 100%; | |
| height: 100%; | |
| margin: 0; | |
| overflow: hidden; | |
| background: | |
| radial-gradient(circle at 50% 22%, rgba(85, 120, 180, 0.20), transparent 30%), | |
| radial-gradient(circle at 50% 74%, rgba(120, 150, 235, 0.10), transparent 38%), | |
| linear-gradient(180deg, var(--bg0) 0%, var(--bg1) 55%, #020406 100%); | |
| color: var(--text); | |
| font-family: "Trebuchet MS", "Verdana", "Segoe UI", sans-serif; | |
| letter-spacing: 0.02em; | |
| } | |
| .app { | |
| position: relative; | |
| width: 100%; | |
| height: 100%; | |
| perspective: var(--perspective); | |
| perspective-origin: 50% 50%; | |
| overflow: hidden; | |
| } | |
| .scanlines { | |
| position: absolute; | |
| inset: 0; | |
| pointer-events: none; | |
| background: linear-gradient(to bottom, rgba(255,255,255,0.03) 1px, transparent 1px); | |
| background-size: 100% 4px; | |
| opacity: 0.14; | |
| mix-blend-mode: screen; | |
| } | |
| .vignette { | |
| position: absolute; | |
| inset: 0; | |
| pointer-events: none; | |
| background: radial-gradient(circle at center, transparent 0 54%, rgba(0,0,0,0.5) 100%); | |
| } | |
| .hud { | |
| position: absolute; | |
| left: 3vw; | |
| top: 3vh; | |
| z-index: 5; | |
| color: var(--muted); | |
| font-size: 12px; | |
| line-height: 1.5; | |
| letter-spacing: 0.12em; | |
| text-transform: uppercase; | |
| text-shadow: 0 0 10px rgba(0,0,0,0.65); | |
| user-select: none; | |
| } | |
| .scene { | |
| position: absolute; | |
| inset: 0; | |
| display: grid; | |
| place-items: center; | |
| transform-style: preserve-3d; | |
| } | |
| .stage { | |
| position: relative; | |
| width: min(96vw, 1500px); | |
| height: min(84vh, 920px); | |
| transform-style: preserve-3d; | |
| transform: translateY(6px) rotateX(var(--stage-tilt)); | |
| } | |
| .orbit-anchor { | |
| position: absolute; | |
| left: 50%; | |
| top: 50%; | |
| width: 1px; | |
| height: 1px; | |
| transform-style: preserve-3d; | |
| transform: translate(-50%, -50%); | |
| } | |
| .center-dot { | |
| position: absolute; | |
| left: 50%; | |
| top: 50%; | |
| width: 10px; | |
| height: 10px; | |
| margin-left: -5px; | |
| margin-top: -5px; | |
| border-radius: 999px; | |
| background: rgba(255,255,255,0.82); | |
| box-shadow: 0 0 14px rgba(255,255,255,0.18); | |
| opacity: 0.5; | |
| } | |
| .node { | |
| position: absolute; | |
| left: 0; | |
| top: 0; | |
| transform-style: preserve-3d; | |
| backface-visibility: hidden; | |
| will-change: transform, opacity; | |
| pointer-events: none; | |
| transform-origin: center center; | |
| } | |
| .face { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transform-style: preserve-3d; | |
| backface-visibility: hidden; | |
| white-space: nowrap; | |
| border-radius: 18px; | |
| overflow: hidden; | |
| transform-origin: center center; | |
| } | |
| .label-face { | |
| width: var(--label-w); | |
| height: var(--label-h); | |
| border: 1px solid rgba(255,255,255,0.14); | |
| background: | |
| linear-gradient(180deg, rgba(255,255,255,0.12), rgba(255,255,255,0.03)), | |
| linear-gradient(90deg, rgba(25, 37, 58, 0.90), rgba(10, 15, 26, 0.62)); | |
| color: #f4f8ff; | |
| font-size: 19px; | |
| font-weight: 800; | |
| letter-spacing: 0.08em; | |
| text-shadow: 0 1px 0 rgba(0,0,0,0.55); | |
| box-shadow: | |
| 0 18px 30px rgba(0,0,0,0.28), | |
| inset 0 1px 0 rgba(255,255,255,0.12); | |
| } | |
| .label-text { | |
| display: inline-block; | |
| padding: 0 12px; | |
| transform: translateZ(0); | |
| } | |
| .divider-face { | |
| width: var(--divider-w); | |
| height: var(--divider-h); | |
| border-radius: 999px; | |
| border: 1px solid rgba(255,255,255,0.08); | |
| background: linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02)); | |
| color: rgba(255,255,255,0.94); | |
| font-size: 20px; | |
| font-weight: 800; | |
| letter-spacing: 0.34em; | |
| text-shadow: 0 0 8px rgba(255,255,255,0.20); | |
| box-shadow: inset 0 1px 0 rgba(255,255,255,0.05); | |
| } | |
| .divider-face img { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: contain; | |
| display: block; | |
| image-rendering: pixelated; | |
| } | |
| .divider-face.fallback::before { | |
| content: "*"; | |
| } | |
| .title-panel { | |
| position: absolute; | |
| left: 50%; | |
| top: 68%; | |
| transform: translate(-50%, -50%) translateZ(150px); | |
| width: var(--panel-w); | |
| padding: 18px 24px 20px; | |
| border: 1px solid rgba(255,255,255,0.10); | |
| border-radius: 20px; | |
| background: linear-gradient(180deg, rgba(5,10,18,0.72), rgba(5,10,18,0.34)); | |
| box-shadow: 0 18px 44px rgba(0,0,0,0.42), inset 0 1px 0 rgba(255,255,255,0.05); | |
| backdrop-filter: blur(6px); | |
| text-align: center; | |
| z-index: 2; | |
| user-select: none; | |
| } | |
| .current-label { | |
| font-size: clamp(24px, 3vw, 40px); | |
| font-weight: 900; | |
| color: #f6faff; | |
| text-transform: uppercase; | |
| letter-spacing: 0.10em; | |
| text-shadow: 0 0 14px rgba(137,184,255,0.16); | |
| } | |
| .sublist { | |
| margin-top: 10px; | |
| color: rgba(236,243,255,0.72); | |
| font-size: 12px; | |
| letter-spacing: 0.10em; | |
| text-transform: uppercase; | |
| display: grid; | |
| grid-auto-flow: column; | |
| justify-content: center; | |
| gap: 10px; | |
| min-height: 16px; | |
| } | |
| .sublist span { | |
| opacity: 0.9; | |
| white-space: nowrap; | |
| } | |
| .footer { | |
| position: absolute; | |
| left: 3vw; | |
| bottom: 3vh; | |
| z-index: 5; | |
| color: rgba(233,240,255,0.50); | |
| font-size: 11px; | |
| letter-spacing: 0.12em; | |
| text-transform: uppercase; | |
| user-select: none; | |
| } | |
| .footer strong { | |
| color: rgba(233,240,255,0.82); | |
| font-weight: 700; | |
| } | |
| @media (max-width: 760px) { | |
| :root { | |
| --perspective: 950px; | |
| --orbit-x: 230px; | |
| --orbit-y: 32px; | |
| --orbit-z: 112px; | |
| --label-w: 154px; | |
| --label-h: 46px; | |
| --divider-w: 92px; | |
| --divider-h: 26px; | |
| } | |
| .title-panel { top: 72%; } | |
| .label-face { font-size: 15px; } | |
| .sublist { gap: 8px; font-size: 11px; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="app" id="app" tabindex="0" aria-label="Demo One style orbit menu"> | |
| <div class="scanlines"></div> | |
| <div class="vignette"></div> | |
| <div class="hud"> | |
| DEMO ONE-INSPIRED ORBIT MENU<br> | |
| ARROW LEFT / RIGHT TO SWITCH ITEMS<br> | |
| ARROW UP / DOWN TO SWITCH SUBITEMS | |
| </div> | |
| <div class="scene"> | |
| <div class="stage"> | |
| <div class="orbit-anchor"> | |
| <div class="center-dot"></div> | |
| <div id="orbit"></div> | |
| </div> | |
| <div class="title-panel"> | |
| <div class="current-label" id="currentLabel">Games</div> | |
| <div class="sublist" id="sublist"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="footer"> | |
| <strong>Hint:</strong> divider fallbacks use a star until you add an image to <code>dividerAssets</code>. | |
| </div> | |
| </div> | |
| <script> | |
| const menuItems = [ | |
| { title: 'Games', items: ['Start', 'Load', 'Options', 'Credits'] }, | |
| { title: 'Videos', items: ['Trailer One', 'Trailer Two', 'Play All', 'Scene Select'] }, | |
| { title: 'Reviews', items: ['Latest', 'Top Rated', 'Editor Picks', 'Archive'] } | |
| ]; | |
| const dividerAssets = [null, null, null]; | |
| const orbit = document.getElementById('orbit'); | |
| const currentLabel = document.getElementById('currentLabel'); | |
| const sublist = document.getElementById('sublist'); | |
| const app = document.getElementById('app'); | |
| const state = { | |
| activeIndex: 0, | |
| subIndex: 0, | |
| phase: 0, | |
| targetPhase: 0, | |
| autoPhase: 0, | |
| lastTime: performance.now(), | |
| config: { | |
| x: 350, | |
| y: 60, | |
| z: 190, | |
| speed: 14 | |
| }, | |
| nodes: [] | |
| }; | |
| function readConfig() { | |
| const cs = getComputedStyle(document.documentElement); | |
| state.config.x = parseFloat(cs.getPropertyValue('--orbit-x')) || state.config.x; | |
| state.config.y = parseFloat(cs.getPropertyValue('--orbit-y')) || state.config.y; | |
| state.config.z = parseFloat(cs.getPropertyValue('--orbit-z')) || state.config.z; | |
| state.config.speed = parseFloat(cs.getPropertyValue('--spin-speed')) || state.config.speed; | |
| } | |
| function makeNode(kind, index) { | |
| const node = document.createElement('div'); | |
| node.className = 'node'; | |
| node.dataset.kind = kind; | |
| node.dataset.index = String(index); | |
| const face = document.createElement('div'); | |
| face.className = 'face ' + (kind === 'label' ? 'label-face' : 'divider-face fallback'); | |
| if (kind === 'label') { | |
| const text = document.createElement('span'); | |
| text.className = 'label-text'; | |
| text.textContent = menuItems[state.activeIndex].title; | |
| face.appendChild(text); | |
| } else { | |
| const asset = dividerAssets[index % dividerAssets.length]; | |
| if (asset) { | |
| face.classList.remove('fallback'); | |
| const img = document.createElement('img'); | |
| img.src = asset; | |
| img.alt = '*'; | |
| face.appendChild(img); | |
| } else { | |
| face.textContent = '*'; | |
| } | |
| } | |
| node.appendChild(face); | |
| orbit.appendChild(node); | |
| state.nodes.push({ node, face, kind, index, text: face.querySelector('.label-text') }); | |
| } | |
| // One orbit: label, divider, label, divider, label, divider. | |
| for (let i = 0; i < menuItems.length; i++) { | |
| makeNode('label', i); | |
| makeNode('divider', i); | |
| } | |
| function wrapDeg(deg) { | |
| return ((deg % 360) + 360) % 360; | |
| } | |
| function updatePanels() { | |
| currentLabel.textContent = menuItems[state.activeIndex].title; | |
| sublist.innerHTML = ''; | |
| for (const item of menuItems[state.activeIndex].items) { | |
| const span = document.createElement('span'); | |
| const marker = document.createElement('span'); | |
| marker.textContent = item; | |
| marker.style.opacity = item === menuItems[state.activeIndex].items[state.subIndex] ? '1' : '0.72'; | |
| sublist.appendChild(marker); | |
| } | |
| // Duplicate the selected title across the ring labels. | |
| for (const n of state.nodes) { | |
| if (n.kind === 'label' && n.text) n.text.textContent = menuItems[state.activeIndex].title; | |
| } | |
| } | |
| function matrix(x, y, z, rx, ry, rz, sx, sy, sz) { | |
| const m = new DOMMatrix(); | |
| m.translateSelf(x, y, z); | |
| m.rotateSelf(rx, ry, rz); | |
| m.scaleSelf(sx, sy, sz); | |
| return m.toString(); | |
| } | |
| function renderOrbit() { | |
| const count = state.nodes.length; | |
| const step = 360 / count; | |
| const labelStep = 360 / menuItems.length; | |
| const centerLabel = Math.round(wrapDeg(-state.phase) / labelStep) % menuItems.length; | |
| for (let i = 0; i < count; i++) { | |
| const item = state.nodes[i]; | |
| const isDivider = item.kind === 'divider'; | |
| const angleDeg = (i * step) + state.phase + (isDivider ? step * 0.5 : 0); | |
| const a = angleDeg * Math.PI / 180; | |
| const x = Math.sin(a) * state.config.x; | |
| const y = Math.sin(a * 0.5) * state.config.y; | |
| const z = Math.cos(a) * state.config.z; | |
| const depth = (z + state.config.z) / (state.config.z * 2); | |
| const opacity = 0.55 + depth * 0.45; | |
| // Outer node follows the orbit on a globe-like path. | |
| const yaw = -Math.sin(a) * 64; | |
| const pitch = Math.sin(a) * 18; | |
| const roll = Math.sin(a * 0.72) * 8; | |
| const outerScale = isDivider ? (0.96 + depth * 0.08) : (0.93 + depth * 0.10); | |
| item.node.style.transform = matrix(x, y, z, pitch, yaw, roll, outerScale, outerScale, 1); | |
| item.node.style.opacity = opacity.toFixed(2); | |
| item.node.style.zIndex = String(Math.round(depth * 1000) + (isDivider ? 0 : 100)); | |
| // Inner face counter-rotates, keeping text readable while still curving around the path. | |
| const faceYaw = -yaw * 0.72; | |
| const facePitch = -pitch * 0.48; | |
| const faceRoll = -roll * 0.20; | |
| const faceScale = isDivider ? 1 : (0.98 + depth * 0.02); | |
| item.face.style.transform = matrix(0, 0, 0, facePitch, faceYaw, faceRoll, faceScale, faceScale, 1); | |
| item.node.classList.toggle('active', item.kind === 'label' && item.index === centerLabel); | |
| } | |
| } | |
| function moveMenu(delta) { | |
| state.activeIndex = (state.activeIndex + delta + menuItems.length) % menuItems.length; | |
| state.subIndex = 0; | |
| state.targetPhase -= delta * (360 / menuItems.length); | |
| updatePanels(); | |
| } | |
| function moveSub(delta) { | |
| const items = menuItems[state.activeIndex].items; | |
| state.subIndex = (state.subIndex + delta + items.length) % items.length; | |
| updatePanels(); | |
| } | |
| function tick(now) { | |
| const dt = Math.min(0.033, (now - state.lastTime) / 1000); | |
| state.lastTime = now; | |
| state.autoPhase -= state.config.speed * dt; | |
| const desired = state.targetPhase + state.autoPhase; | |
| const ease = 1 - Math.pow(0.0001, dt); | |
| state.phase += (desired - state.phase) * ease; | |
| renderOrbit(); | |
| requestAnimationFrame(tick); | |
| } | |
| document.addEventListener('keydown', (e) => { | |
| if (e.key === 'ArrowLeft') { | |
| e.preventDefault(); | |
| moveMenu(-1); | |
| } else if (e.key === 'ArrowRight') { | |
| e.preventDefault(); | |
| moveMenu(1); | |
| } else if (e.key === 'ArrowUp') { | |
| e.preventDefault(); | |
| moveSub(-1); | |
| } else if (e.key === 'ArrowDown') { | |
| e.preventDefault(); | |
| moveSub(1); | |
| } | |
| }); | |
| window.addEventListener('load', () => { | |
| readConfig(); | |
| app.focus(); | |
| updatePanels(); | |
| renderOrbit(); | |
| requestAnimationFrame(tick); | |
| }); | |
| window.addEventListener('resize', () => { | |
| readConfig(); | |
| renderOrbit(); | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment