Instantly share code, notes, and snippets.
Created
May 17, 2026 10:56
-
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/476401b0f85cd4589f6ab38b2ad4d434 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.62); | |
| --perspective: 1400px; | |
| --orbit-x: 340px; | |
| --orbit-y: 54px; | |
| --orbit-z: 180px; | |
| --label-w: 200px; | |
| --label-h: 56px; | |
| --divider-w: 120px; | |
| --divider-h: 32px; | |
| --spin-speed: 16; /* degrees per second */ | |
| --stage-tilt: 18deg; | |
| --glow: 0 0 24px rgba(137, 184, 255, 0.22); | |
| } | |
| * { box-sizing: border-box; } | |
| html, body { | |
| margin: 0; | |
| width: 100%; | |
| height: 100%; | |
| 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.16; | |
| 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.48) 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; | |
| user-select: none; | |
| text-shadow: 0 0 10px rgba(0,0,0,0.65); | |
| } | |
| .scene { | |
| position: absolute; | |
| inset: 0; | |
| display: grid; | |
| place-items: center; | |
| transform-style: preserve-3d; | |
| } | |
| .stage { | |
| position: relative; | |
| width: min(96vw, 1500px); | |
| height: min(82vh, 900px); | |
| transform-style: preserve-3d; | |
| transform: translateY(8px) 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: 14px; | |
| height: 14px; | |
| margin-left: -7px; | |
| margin-top: -7px; | |
| border-radius: 999px; | |
| background: rgba(255, 255, 255, 0.84); | |
| box-shadow: 0 0 18px rgba(255,255,255,0.26); | |
| opacity: 0.72; | |
| } | |
| .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; | |
| } | |
| .card { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transform-style: preserve-3d; | |
| backface-visibility: hidden; | |
| white-space: nowrap; | |
| border-radius: 18px; | |
| overflow: hidden; | |
| } | |
| .label-card { | |
| 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.32), | |
| inset 0 1px 0 rgba(255,255,255,0.12), | |
| var(--glow); | |
| position: relative; | |
| } | |
| .label-card::before { | |
| content: ""; | |
| position: absolute; | |
| inset: 0; | |
| background: linear-gradient(90deg, transparent, rgba(255,255,255,0.09), transparent); | |
| opacity: 0.18; | |
| } | |
| .label-card.active { | |
| border-color: rgba(146, 190, 255, 0.66); | |
| background: | |
| linear-gradient(180deg, rgba(146, 190, 255, 0.28), rgba(255,255,255,0.04)), | |
| linear-gradient(90deg, rgba(33, 58, 96, 0.98), rgba(10, 15, 26, 0.72)); | |
| box-shadow: | |
| 0 22px 38px rgba(0,0,0,0.38), | |
| 0 0 30px rgba(146, 190, 255, 0.35), | |
| inset 0 1px 0 rgba(255,255,255,0.16); | |
| } | |
| .divider-card { | |
| 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.92); | |
| font-size: 20px; | |
| font-weight: 800; | |
| letter-spacing: 0.3em; | |
| text-shadow: 0 0 8px rgba(255,255,255,0.20); | |
| box-shadow: inset 0 1px 0 rgba(255,255,255,0.05); | |
| } | |
| .divider-card img { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: contain; | |
| display: block; | |
| image-rendering: pixelated; | |
| } | |
| .divider-card.fallback::before { | |
| content: "*"; | |
| } | |
| .title-panel { | |
| position: absolute; | |
| left: 50%; | |
| top: 71%; | |
| transform: translate(-50%, -50%) translateZ(140px); | |
| width: min(620px, 82vw); | |
| padding: 16px 20px; | |
| border: 1px solid rgba(255,255,255,0.10); | |
| border-radius: 20px; | |
| background: linear-gradient(180deg, rgba(5,10,18,0.70), 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; | |
| } | |
| .current-label { | |
| font-size: clamp(24px, 3vw, 40px); | |
| font-weight: 900; | |
| color: #f6faff; | |
| text-transform: uppercase; | |
| letter-spacing: 0.10em; | |
| text-shadow: 0 0 18px rgba(137,184,255,0.24); | |
| } | |
| .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: 220px; | |
| --orbit-y: 32px; | |
| --orbit-z: 110px; | |
| --label-w: 154px; | |
| --label-h: 46px; | |
| --divider-w: 92px; | |
| --divider-h: 26px; | |
| } | |
| .current-label { font-size: 26px; } | |
| .title-panel { top: 73%; } | |
| .label-card { font-size: 15px; } | |
| } | |
| </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 | |
| </div> | |
| <div class="scene"> | |
| <div class="stage"> | |
| <div class="orbit-anchor" id="orbitAnchor"> | |
| <div class="center-dot"></div> | |
| <div id="orbit"></div> | |
| </div> | |
| <div class="title-panel"> | |
| <div class="current-label" id="currentLabel">Games</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 = ["Games", "Videos", "Reviews"]; | |
| const dividerAssets = [null, null, null]; | |
| const orbit = document.getElementById('orbit'); | |
| const currentLabel = document.getElementById('currentLabel'); | |
| const app = document.getElementById('app'); | |
| const state = { | |
| activeIndex: 0, | |
| phase: 0, | |
| targetPhase: 0, | |
| autoPhase: 0, | |
| lastTime: performance.now(), | |
| config: { | |
| x: 340, | |
| y: 54, | |
| z: 180, | |
| speed: 16 | |
| }, | |
| 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 createCard(kind, title, index) { | |
| const node = document.createElement('div'); | |
| node.className = 'node'; | |
| node.dataset.kind = kind; | |
| node.dataset.index = String(index); | |
| const card = document.createElement('div'); | |
| card.className = 'card ' + (kind === 'label' ? 'label-card' : 'divider-card fallback'); | |
| if (kind === 'label') { | |
| card.textContent = title; | |
| } else { | |
| const asset = dividerAssets[index % dividerAssets.length]; | |
| if (asset) { | |
| card.classList.remove('fallback'); | |
| const img = document.createElement('img'); | |
| img.src = asset; | |
| img.alt = '*'; | |
| card.appendChild(img); | |
| } else { | |
| card.textContent = '*'; | |
| } | |
| } | |
| node.appendChild(card); | |
| orbit.appendChild(node); | |
| state.nodes.push({ node, card, kind, index }); | |
| } | |
| // Visible sequence around the orbit: label, divider, label, divider, label, divider. | |
| for (let i = 0; i < menuItems.length; i++) { | |
| createCard('label', menuItems[i], i); | |
| createCard('divider', '', i); | |
| } | |
| function wrapDeg(deg) { | |
| return ((deg % 360) + 360) % 360; | |
| } | |
| function setTitle() { | |
| currentLabel.textContent = menuItems[state.activeIndex]; | |
| state.nodes.forEach(n => { | |
| if (n.kind === 'label') n.card.textContent = menuItems[n.index]; | |
| }); | |
| } | |
| function transform3d(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 render() { | |
| 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 offset = isDivider ? step * 0.5 : 0; | |
| const aDeg = i * step + state.phase + offset; | |
| const a = aDeg * 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; | |
| // Keep everything visible: never let opacity fall too low. | |
| const depth = (z + state.config.z) / (state.config.z * 2); | |
| const opacity = 0.72 + depth * 0.28; | |
| const scale = isDivider ? (0.95 + depth * 0.08) : (0.94 + depth * 0.10); | |
| // 3D globe bend and facing. | |
| const yaw = -Math.sin(a) * 56; | |
| const pitch = Math.sin(a) * 14; | |
| const roll = Math.sin(a * 0.72) * 8; | |
| const squashX = isDivider ? 1.08 : 0.96; | |
| const squashY = 1.0; | |
| item.node.style.transform = transform3d(x, y, z, pitch, yaw, roll, scale * squashX, scale * squashY, 1); | |
| item.node.style.opacity = opacity.toFixed(2); | |
| item.node.style.zIndex = String(Math.round(depth * 1000) + (isDivider ? 0 : 100)); | |
| // Counter-rotate the face a little so the text curves with the orbit but remains readable. | |
| const fx = 0; | |
| const fy = 0; | |
| const fz = 0; | |
| const faceYaw = -yaw * 0.55; | |
| const facePitch = -pitch * 0.35; | |
| const faceRoll = -roll * 0.15; | |
| item.card.style.transform = transform3d(fx, fy, fz, facePitch, faceYaw, faceRoll, 1, 1, 1); | |
| item.node.classList.toggle('active', item.kind === 'label' && item.index === centerLabel); | |
| } | |
| } | |
| function move(delta) { | |
| state.activeIndex = (state.activeIndex + delta + menuItems.length) % menuItems.length; | |
| state.targetPhase -= delta * (360 / menuItems.length); | |
| setTitle(); | |
| } | |
| 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; | |
| render(); | |
| requestAnimationFrame(tick); | |
| } | |
| document.addEventListener('keydown', (e) => { | |
| if (e.key === 'ArrowLeft') { | |
| e.preventDefault(); | |
| move(-1); | |
| } else if (e.key === 'ArrowRight') { | |
| e.preventDefault(); | |
| move(1); | |
| } | |
| }); | |
| window.addEventListener('load', () => { | |
| readConfig(); | |
| setTitle(); | |
| app.focus(); | |
| render(); | |
| requestAnimationFrame(tick); | |
| }); | |
| window.addEventListener('resize', () => { | |
| readConfig(); | |
| render(); | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment