Skip to content

Instantly share code, notes, and snippets.

@EncodeTheCode
Created March 10, 2026 01:41
Show Gist options
  • Select an option

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

Select an option

Save EncodeTheCode/ccfd18b9ef9e4aa3d7d043486d1aedab 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" />
<title>Demo One — Robust Globe Ribbon Menu</title>
<!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<style>
:root{
--menu-radius: 360px; /* radius of the ring */
--label-size: 48px; /* font size */
--dot-size: 30px;
--ribbon-thickness: 72px;
}
html,body{
height:100%; margin:0; background:#00121a; overflow:hidden;
font-family: "Trebuchet MS", Arial, sans-serif;
-webkit-font-smoothing:antialiased; -moz-osx-font-smoothing:grayscale;
}
/* pearl-noise canvas */
#bgCanvas{
position:fixed; inset:0; width:100%; height:100%; z-index:0;
background: linear-gradient(#042833,#082a40 30%, #042a3d 80%);
}
/* stage with perspective */
.stage{
position:relative; width:100%; height:100%;
display:flex; align-items:center; justify-content:center;
perspective:1100px;
z-index:10;
}
/* UI wrapper */
.ui{
width:94%; max-width:1400px; height:86%;
display:flex; align-items:center; justify-content:center;
position:relative;
}
/* menu container (camera). very high z-index to ensure ribbons are on top */
.menu-wrapper{
width:980px; height:520px; position:relative; transform-style:preserve-3d;
z-index:3000;
}
.menu3d{
position:absolute; inset:0; transform-style:preserve-3d;
/* camera translateZ will be set in JS to -radius */
z-index:3100;
pointer-events:none;
}
/* each menu item is absolutely centered at 50%/50% and transformed in JS */
.menu-item{
position:absolute; left:50%; top:50%;
transform-origin:center center;
will-change:transform,opacity;
pointer-events:none;
backface-visibility:visible;
transform-style:preserve-3d;
transition: opacity 260ms, transform 360ms;
}
/* Ribbon SVG is centered at (0,0) using viewBox; make it large enough to avoid clipping */
svg.ribbon{
overflow:visible; display:block; pointer-events:none;
width:760px; height:260px; /* large enough and consistent */
}
/* ribbon visuals */
svg.ribbon .ribbon-shape{
filter: drop-shadow(0 18px 20px rgba(0,0,0,0.55));
transition: filter 280ms, opacity 280ms;
}
svg.ribbon text{
font-weight:900; font-size:var(--label-size); dominant-baseline:middle; text-anchor:middle;
pointer-events:none;
}
svg.ribbon .dot{
transition: transform 260ms, opacity 260ms;
}
/* front/side/back emphasis */
.menu-item.front{ opacity:1; z-index:4000; }
.menu-item.side{ opacity:0.36; z-index:3200; }
.menu-item.back{ opacity:0.16; z-index:3000; filter:brightness(.8) blur(.2px); }
/* a simple fallback label in case svg has trouble - always visible on top of SVG */
.label-fallback{
position:absolute; left:50%; top:50%; transform:translate(-50%,-50%);
font-weight:900; font-size:var(--label-size); color:#dfffa8;
text-shadow:0 6px 18px rgba(0,0,0,0.6);
pointer-events:none; display:none; white-space:nowrap;
}
/* star sits behind ribbons (lower z-index than menu3d) */
.star-wrap{
position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); width:420px; height:420px;
pointer-events:none; z-index:2000; display:flex; align-items:center; justify-content:center;
}
#starImg{ max-width:100%; max-height:100%; transform-origin:center center; will-change:transform,opacity; filter: drop-shadow(0 22px 24px rgba(0,0,0,0.55)); opacity:0.98; }
/* controls and help overlays (above everything) */
.controls{ position:fixed; right:18px; top:18px; z-index:5000; background:rgba(0,0,0,0.28); padding:10px 12px; border-radius:10px; color:#dfffe5; font-size:13px; pointer-events:auto; }
.controls input[type="range"]{ width:170px; margin-top:6px; display:block; }
.controls label{ display:block; font-weight:700; margin-bottom:6px; }
.detail{ position:absolute; left:50%; bottom:6%; transform:translateX(-50%); z-index:5000; color:#eaffc8; font-size:20px; pointer-events:none; text-shadow: 0 2px 8px rgba(0,0,0,0.6); }
.help{ position:fixed; left:18px; bottom:18px; z-index:5000; color:rgba(255,255,255,0.78); font-size:12px; pointer-events:auto; background:rgba(0,0,0,0.24); padding:8px 10px; border-radius:8px; }
/* responsive */
@media (max-width:900px){
svg.ribbon{ width:520px; height:200px; }
:root{ --menu-radius:260px; --label-size:36px; --ribbon-thickness:56px; }
}
</style>
</head>
<body>
<canvas id="bgCanvas"></canvas>
<div class="stage">
<div class="ui">
<div class="menu-wrapper">
<!-- 3D camera container (translateZ(-radius) set in JS) -->
<div class="menu3d" id="menu3d"></div>
<!-- star behind ribbons -->
<div class="star-wrap" id="starWrap" aria-hidden="true"><img id="starImg" src="" alt="star"/></div>
<div class="detail" id="detail"><span id="detailMain">Games</span><span id="detailSub" style="margin-left:12px;color:#c6ffd8;font-weight:800"></span></div>
</div>
</div>
</div>
<div class="controls">
<label>Rotation speed (deg/sec, negative = left): <span id="speedVal">-6</span></label>
<input id="speedRange" type="range" min="-60" max="60" value="-6" step="1"/>
<div style="height:8px"></div>
<label>Star PNG (optional)</label>
<input id="starFile" type="file" accept="image/*"/>
<div style="height:8px"></div>
<button id="resetBtn" style="padding:6px 8px; border-radius:6px;">Reset view</button>
</div>
<div class="help">Arrow ← / → — rotate. When <strong>Games</strong> is front use ←/→ to cycle games.</div>
<script>
/* ---------------------------
Pearl-noise animated background
--------------------------- */
(function(){
const canvas = document.getElementById('bgCanvas'), ctx = canvas.getContext('2d');
let W = canvas.width = innerWidth, H = canvas.height = innerHeight;
const PALETTE = [[6,128,120],[8,92,168],[24,200,190],[56,168,220],[14,94,110]];
const BLOBS = 36;
const blobs = [];
for(let i=0;i<BLOBS;i++){
blobs.push({
x: Math.random()*W,
y: Math.random()*H,
r: Math.max(140, Math.round(Math.max(W,H) * 0.12)) * (0.7 + Math.random()*1.2),
vx: (Math.random()*2-1)*0.06,
vy: (Math.random()*2-1)*0.06,
ci: Math.floor(Math.random()*PALETTE.length),
alpha: 0.18 + Math.random()*0.46
});
}
let perf = 0;
function draw(){
ctx.clearRect(0,0,W,H);
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.filter = 'blur(22px)';
for(const b of blobs){
const c = PALETTE[b.ci];
ctx.fillStyle = `rgba(${c[0]},${c[1]},${c[2]},${b.alpha})`;
ctx.beginPath();
ctx.ellipse(b.x,b.y,b.r,b.r*0.92,0,0,Math.PI*2);
ctx.fill();
}
ctx.restore();
ctx.save(); ctx.globalCompositeOperation='overlay'; ctx.filter='blur(14px)';
for(let i=0;i<6;i++){
const x = (i/6)*W + Math.sin(perf*0.0006 + i)*60;
const y = (Math.cos(perf*0.0005 + i)*80) + H*0.33;
const g = ctx.createRadialGradient(x,y,20,x,y,Math.min(W,H)*0.7);
g.addColorStop(0,'rgba(255,255,255,0.03)');
g.addColorStop(1,'rgba(255,255,255,0.0)');
ctx.fillStyle = g;
ctx.fillRect(0,0,W,H);
}
ctx.restore();
ctx.save();
const og = ctx.createLinearGradient(0,0,0,H);
og.addColorStop(0,'rgba(0,10,18,0.06)');
og.addColorStop(1,'rgba(0,3,8,0.22)');
ctx.fillStyle = og;
ctx.fillRect(0,0,W,H);
ctx.restore();
}
function step(ts){
perf = ts;
for(const b of blobs){
b.x += b.vx * 0.6; b.y += b.vy * 0.6;
b.x += Math.sin((perf*0.00015) + b.r) * 0.08;
b.y += Math.cos((perf*0.00012) + b.r) * 0.06;
if(b.x < -b.r) b.x = W + b.r;
if(b.x > W + b.r) b.x = -b.r;
if(b.y < -b.r) b.y = H + b.r;
if(b.y > H + b.r) b.y = -b.r;
}
draw();
requestAnimationFrame(step);
}
addEventListener('resize', ()=>{ W = canvas.width = innerWidth; H = canvas.height = innerHeight; });
requestAnimationFrame(step);
})();
/* ---------------------------
Globe ribbon menu (robust visible rendering)
--------------------------- */
$(function(){
// Data
const mainLabels = ["games","video","music","memory","network"];
const gamesList = ["Tomb Raider 3","Tekken 3","Crash Bandicoot","Final Fantasy VII","Metal Gear Solid"];
const menu3d = $('#menu3d');
const n = mainLabels.length;
const angleStep = 360 / n;
// Runtime state
let radius = Number(getComputedStyle(document.documentElement).getPropertyValue('--menu-radius')) || 360;
let ribbonThickness = Number(getComputedStyle(document.documentElement).getPropertyValue('--ribbon-thickness')) || 72;
let currentRotation = 0; // degrees
let speedDeg = Number($('#speedRange').val()); $('#speedVal').text(speedDeg);
let lastTS = performance.now();
let currentGameIndex = 0;
// Create centered ribbon SVG (viewBox centered at 0,0)
function createRibbonSVG(id, labelText, arcAngleDeg, R){
const half = arcAngleDeg/2;
const a1 = -half * Math.PI/180, a2 = half * Math.PI/180;
const outR = Math.max(20, (R * 0.92) + (ribbonThickness/2));
const inR = Math.max(6, (R * 0.92) - (ribbonThickness/2));
const midR = (outR + inR)/2;
// endpoints coordinates
const x1o = outR * Math.sin(a1), y1o = -outR * Math.cos(a1);
const x2o = outR * Math.sin(a2), y2o = -outR * Math.cos(a2);
const x2i = inR * Math.sin(a2), y2i = -inR * Math.cos(a2);
const x1i = inR * Math.sin(a1), y1i = -inR * Math.cos(a1);
const laf = arcAngleDeg > 180 ? 1 : 0;
const pathD = [
`M ${x1o} ${y1o}`,
`A ${outR} ${outR} 0 ${laf} 1 ${x2o} ${y2o}`,
`L ${x2i} ${y2i}`,
`A ${inR} ${inR} 0 ${laf} 0 ${x1i} ${y1i}`,
'Z'
].join(' ');
const xm1 = midR * Math.sin(a1), ym1 = -midR * Math.cos(a1);
const xm2 = midR * Math.sin(a2), ym2 = -midR * Math.cos(a2);
const textPathD = `M ${xm1} ${ym1} A ${midR} ${midR} 0 ${laf} 1 ${xm2} ${ym2}`;
// dot position
const dotOut = outR + (ribbonThickness * 0.06);
const dotX = dotOut * Math.sin(a2), dotY = -dotOut * Math.cos(a2);
// choose a unique gradient id
const gradId = `g${id}_${Date.now()%100000}`;
// compute viewBox extents to be safe
const margin = Math.ceil(ribbonThickness*1.6 + 40);
const extent = Math.ceil(outR + margin);
const viewBox = `${-extent} ${-extent} ${extent*2} ${extent*2}`;
// build SVG string
return `
<svg class="ribbon" viewBox="${viewBox}" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<defs>
<linearGradient id="${gradId}" x1="0" x2="1">
<stop offset="0" stop-color="#c7ff45"/>
<stop offset="1" stop-color="#8ecc23"/>
</linearGradient>
</defs>
<path class="ribbon-shape" d="${pathD}" fill="url(#${gradId})" opacity="0.995" stroke="rgba(0,0,0,0.08)" stroke-width="1.2"/>
<path id="tp_${id}" d="${textPathD}" fill="none" stroke="none"/>
<text>
<textPath href="#tp_${id}" startOffset="50%" text-anchor="middle" dominant-baseline="middle" style="font-family:'Trebuchet MS', Arial, sans-serif; font-weight:900; font-size:${getComputedStyle(document.documentElement).getPropertyValue('--label-size')};">
<tspan fill="url(#${gradId})">${labelText}</tspan>
</textPath>
</text>
<circle class="dot" cx="${dotX}" cy="${dotY}" r="${(parseInt(getComputedStyle(document.documentElement).getPropertyValue('--dot-size'))/2)}" fill="url(#${gradId})" stroke="rgba(0,0,0,0.25)" stroke-width="1.6"/>
</svg>
`;
}
// Build visible menu items (ensures front is index 0 and immediately visible)
function buildMenu(){
menu3d.empty();
for(let i=0;i<n;i++){
const text = mainLabels[i];
const arc = Math.min(90, Math.max(28, text.length * 7.8));
const $it = $(`<div class="menu-item" data-i="${i}" data-arc="${arc}"></div>`);
$it.html(createRibbonSVG(i, text, arc, radius));
// a small fallback label element (rarely needed) placed above the svg but hidden by default
$it.append(`<div class="label-fallback">${text}</div>`);
menu3d.append($it);
}
// camera offset: put camera back by radius so items translateZ(radius) place them at visible plane
menu3d.css('transform', `translateZ(-${radius}px)`);
layoutImmediate();
}
// layout immediate (used after build or resize)
function layoutImmediate(){
menu3d.find('.menu-item').each(function(){
const i = Number($(this).attr('data-i'));
const baseAngle = i * angleStep;
const eff = baseAngle + currentRotation;
const effRad = eff * Math.PI/180;
const curveDeg = Math.sin(effRad) * 20;
const t = `rotateY(${eff}deg) translateZ(${radius}px) translateX(-50%) translateY(-50%) rotateY(${-eff}deg) rotateX(${curveDeg}deg)`;
$(this).css('transform', t);
});
updateClassesAndDetail();
}
// update classes and detail based on currentRotation
function updateClassesAndDetail(){
const normalized = ((-currentRotation % 360)+360)%360;
let front = Math.round(normalized / angleStep) % n;
front = (front + n) % n;
menu3d.find('.menu-item').each(function(){
const i = Number($(this).attr('data-i'));
let d = Math.abs(i - front); d = Math.min(d, n - d);
$(this).removeClass('front side back');
if(i === front) $(this).addClass('front'); else if(d === 1) $(this).addClass('side'); else $(this).addClass('back');
});
$('#detailMain').text(capitalize(mainLabels[front]));
if(mainLabels[front] === 'games') $('#detailSub').text(gamesList[currentGameIndex]); else $('#detailSub').text('');
}
// safe rebuild when window resizes to keep ribbons visible
function rebuildOnResize(){
// recompute radius based on wrapper width
const w = $('.menu-wrapper').width();
radius = Math.max(220, Math.round(w * 0.44));
ribbonThickness = Math.max(46, Math.round(radius * 0.18));
document.documentElement.style.setProperty('--ribbon-thickness', ribbonThickness + 'px');
// rebuild items to use the new radius values
buildMenu();
}
$(window).on('resize', rebuildOnResize);
// animate loop: integrate rotation and update item transforms every frame
function animateFrame(ts){
const dt = (ts - lastTS) / 1000;
lastTS = ts;
currentRotation += speedDeg * dt;
currentRotation = ((currentRotation % 360) + 360) % 360;
// update transforms per item
menu3d.find('.menu-item').each(function(){
const i = Number($(this).attr('data-i'));
const baseAngle = i * angleStep;
const eff = baseAngle + currentRotation;
const effRad = eff * Math.PI/180;
const curveDeg = Math.sin(effRad) * 20; // controls how much it tilts on the globe
const t = `rotateY(${eff}deg) translateZ(${radius}px) translateX(-50%) translateY(-50%) rotateY(${-eff}deg) rotateX(${curveDeg}deg)`;
$(this).css('transform', t);
});
updateClassesAndDetail();
requestAnimationFrame(animateFrame);
}
// initial build and start
rebuildOnResize(); // builds and places items
lastTS = performance.now();
requestAnimationFrame(animateFrame);
// Controls: speed slider
$('#speedRange').on('input change', function(){
speedDeg = Number($(this).val());
$('#speedVal').text(speedDeg);
});
// Keyboard nav & behavior matching your request (games cycles inner list)
function snapToIndex(idx, duration=520){
const targetAngle = -idx * angleStep;
let k = Math.round((currentRotation - targetAngle) / 360);
let target = targetAngle + 360*k;
$({r: currentRotation}).stop(true).animate({r: target}, {
duration: duration, easing: 'swing',
step: function(now){ currentRotation = ((now % 360)+360)%360; },
complete: function(){ currentRotation = ((target%360)+360)%360; }
});
}
function handleLeftRight(isRight){
const normalized = ((-currentRotation % 360)+360)%360;
let front = Math.round(normalized/angleStep) % n; front = (front + n) % n;
if(mainLabels[front] === 'games'){
currentGameIndex = (currentGameIndex + (isRight?1:-1) + gamesList.length) % gamesList.length;
$('#detailSub').stop(true).fadeOut(80, function(){ $(this).text(gamesList[currentGameIndex]).fadeIn(240); });
} else {
const next = (front + (isRight?1:-1) + n) % n;
snapToIndex(next, 520);
currentGameIndex = 0;
}
}
$(window).on('keydown', function(e){
if(e.which === 37){ e.preventDefault(); handleLeftRight(false); }
else if(e.which === 39){ e.preventDefault(); handleLeftRight(true); }
else if(e.which === 13){ e.preventDefault(); $('.menu-item.front').toggleClass('selected'); $('.menu-item.front').animate({opacity:0.86},120).animate({opacity:1},200); }
else if(e.which === 27){ e.preventDefault(); snapToIndex(0,600); }
});
// Star PNG support: try starfish.png then fallback
const starImg = document.getElementById('starImg');
const fallbackStar = `data:image/svg+xml;utf8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 600"><defs><linearGradient id="g" x1="0" x2="1"><stop offset="0" stop-color="#ff7a2c"/><stop offset="1" stop-color="#d92c2c"/></linearGradient></defs><path d="M300 40 C350 120 430 140 520 120 C450 200 480 300 420 380 C320 330 260 400 200 520 C120 400 40 320 -20 220 C20 170 40 120 120 80 C200 40 260 10 300 40 Z" fill="url(#g)" opacity="0.98" /></svg>')}`;
fetch('starfish.png',{method:'HEAD'}).then(r=>{ if(r.ok) starImg.src='starfish.png'; else starImg.src=fallbackStar; }).catch(_=>{ starImg.src=fallbackStar; });
$('#starFile').on('change', function(ev){
const f = ev.target.files && ev.target.files[0];
if(!f) return;
const r = new FileReader();
r.onload = function(e){ starImg.src = e.target.result; };
r.readAsDataURL(f);
});
// subtle star spin (kept behind ribbons)
(function spin(){
let a=0;
(function loop(){ a += 0.032; $('#starImg').css('transform', `rotate(${a}deg)`); requestAnimationFrame(loop); })();
})();
// reset button
$('#resetBtn').on('click', function(){ snapToIndex(0,600); $('#speedRange').val(-6).trigger('change'); });
// tiny helper
function capitalize(s){ return s.charAt(0).toUpperCase() + s.slice(1); }
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment