Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

Save EncodeTheCode/012e62ca76cd0641a69d2e41a5fc6037 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>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