Skip to content

Instantly share code, notes, and snippets.

@EncodeTheCode
Created May 17, 2026 11:09
Show Gist options
  • Select an option

  • Save EncodeTheCode/f0598eefcabe749cfa62e0bd5704b8e6 to your computer and use it in GitHub Desktop.

Select an option

Save EncodeTheCode/f0598eefcabe749cfa62e0bd5704b8e6 to your computer and use it in GitHub Desktop.
<!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