Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

Save EncodeTheCode/7858a6c3ac719b61089ecd18dc23f174 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 — Globe Ribbon Menu (fixed visibility + 3D ribbons)</title>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<style>
:root{
--menu-radius: 380px;
--label-size: 48px;
--dot-size: 30px;
--ribbon-thickness: 72px;
}
html,body{height:100%; margin:0; background:#00121a; overflow:hidden; font-family:"Trebuchet MS", Arial, sans-serif;}
/* background canvas (pearlescent) */
#bgCanvas{ position:fixed; inset:0; width:100%; height:100%; z-index:0; display:block; background:linear-gradient(#042833,#082a40 30%, #042a3d 80%); }
/* Stage */
.stage{ position:relative; width:100%; height:100%; display:flex; align-items:center; justify-content:center; perspective:1100px; z-index:10; }
/* UI container */
.ui{ width:94%; max-width:1280px; height:86%; display:flex; align-items:center; justify-content:center; position:relative; pointer-events:none; }
/* wrapper for 3D area */
.menu-wrapper{
width:920px;
height:520px;
position:relative;
transform-style:preserve-3d;
pointer-events:none;
z-index:40; /* ensure menu area sits above background */
}
/* main 3D camera container (we translateZ(-radius) on it) */
.menu3d{
position:absolute;
inset:0;
transform-style:preserve-3d;
pointer-events:none;
z-index:50; /* highest so ribbons appear on top */
}
/* each item is centered at (50%,50%) and then transformed in JS */
.menu-item{
position:absolute;
left:50%;
top:50%;
width:auto;
height:auto;
transform-origin:center center;
will-change:transform,opacity;
pointer-events:none;
display:block;
transition: opacity 260ms, transform 360ms;
}
/* SVG ribbon styles (each ribbon is an intact curved pill) */
svg.ribbon{ overflow:visible; display:block; pointer-events:none; }
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; pointer-events:none; }
.menu-item.front svg.ribbon{ filter: none; transform-origin:center center; }
.menu-item.front{ opacity:1; z-index:120; transform-style:preserve-3d; }
.menu-item.front svg.ribbon .ribbon-shape{ opacity:1; }
.menu-item.side{ opacity:0.38; z-index:60; transform-style:preserve-3d; }
.menu-item.back{ opacity:0.18; z-index:20; transform-style:preserve-3d; }
/* star area sits behind the menu ribbons visually */
.star-wrap{ position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); width:420px; height:420px; pointer-events:none; z-index:30; 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)); }
/* small control UI */
.controls{ position:fixed; right:16px; top:16px; z-index:140; 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:140; color:#eaffc8; font-size:20px; pointer-events:none; text-shadow:0 2px 8px rgba(0,0,0,0.6); }
.help{ position:fixed; left:16px; bottom:16px; z-index:140; color:rgba(255,255,255,0.78); font-size:12px; background:rgba(0,0,0,0.24); padding:8px 10px; border-radius:8px; pointer-events:auto; }
@media (max-width:920px){
.menu-wrapper{ width:92%; height:420px; }
.star-wrap{ width:320px; height:320px; }
: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">
<div class="menu3d" id="menu3d"></div>
<!-- star (below ribbons in z-index) -->
<div class="star-wrap" id="starWrap" aria-hidden="true">
<img id="starImg" src="" alt="starfish" />
</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>
<!-- controls -->
<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:10px"></div>
<label>Star PNG (optional)</label>
<input id="starFile" type="file" accept="image/*" />
<div style="height:10px"></div>
<button id="resetBtn" style="padding:6px 8px;border-radius:6px;">Reset view</button>
</div>
<div class="help">
Arrow ← / → — rotate / snap. <br/> When <strong>Games</strong> is front, → cycles inner games.
</div>
<script>
/* ---------------------------
Pearlescent background (same approach)
--------------------------- */
(function(){
const canvas = document.getElementById('bgCanvas');
const ctx = canvas.getContext('2d');
let W = canvas.width = innerWidth, H = canvas.height = innerHeight;
const BLOBS = 36;
const BLOB_SIZE = Math.max(160, Math.min(420, Math.round(Math.max(W,H) * 0.12)));
const PALETTE = [[6,128,120],[8,92,168],[24,200,190],[56,168,220],[14,94,110]];
const blobs = [];
for(let i=0;i<BLOBS;i++){
blobs.push({ x: Math.random()*W, y: Math.random()*H, r: BLOB_SIZE * (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.22 + Math.random()*0.42 });
}
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.9,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)'); ctx.fillStyle=g; ctx.fillRect(0,0,W,H); }
ctx.restore();
ctx.save(); const ov = ctx.createLinearGradient(0,0,0,H); ov.addColorStop(0,'rgba(0,10,18,0.06)'); ov.addColorStop(1,'rgba(0,3,8,0.22)'); ctx.fillStyle = ov; 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 implementation
--------------------------- */
$(function(){
// main 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 params
let radius = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--menu-radius')) || 380;
let ribbonThickness = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--ribbon-thickness')) || 72;
let currentRotation = 0; // degrees (0 => index 0 front)
let speedDeg = Number($('#speedRange').val()); $('#speedVal').text(speedDeg);
let lastTS = performance.now();
let currentGameIndex = 0;
// create ribbon SVG centered at 0,0 so transforms work predictably
function createRibbonSVG(id, labelText, arcAngleDeg, R){
const half = arcAngleDeg / 2;
const a1 = -half * Math.PI/180;
const a2 = half * Math.PI/180;
// radii for outside and inside of the pill
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;
// arc endpoints
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;
// compose path (outer arc -> inner arc -> close)
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(' ');
// center arc for text
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 on outer end
const dotOut = outR + (ribbonThickness * 0.06);
const dotX = dotOut * Math.sin(a2), dotY = -dotOut * Math.cos(a2);
const gradId = `grad_${id}`;
// create relatively small viewBox centered at 0,0 (makes transforms simple)
const margin = Math.ceil(ribbonThickness*1.6 + 40);
const extent = Math.ceil(outR + margin);
const viewBox = `${-extent} ${-extent} ${extent*2} ${extent*2}`;
const svg = `
<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.98" 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>
`;
return svg;
}
// build menu items (rebuild after size changes)
function buildItems(){
menu3d.empty();
for(let i=0;i<n;i++){
const label = mainLabels[i];
const approxAngle = Math.min(90, Math.max(28, label.length * 8)); // tune arc span slightly wider
const $it = $(`<div class="menu-item" data-i="${i}" data-arc="${approxAngle}"></div>`);
$it.html(createRibbonSVG(i, label, approxAngle, radius));
menu3d.append($it);
}
// ensure initial placement and classes
updateVisualsImmediate();
}
function updateCameraOffsetAndRebuild(){
const wrapperW = $('.menu-wrapper').width();
radius = Math.max(220, Math.round(wrapperW * 0.42));
// adjust ribbon thickness based on viewport (keeps proportions)
ribbonThickness = Math.max(46, Math.round(radius * 0.18));
document.documentElement.style.setProperty('--ribbon-thickness', ribbonThickness + 'px');
// translate camera back so items placed at translateZ(radius) show correctly
menu3d.css('transform', `translateZ(-${radius}px)`);
// rebuild SVGs to match new radius (ensures arcs are correct)
buildItems();
}
// initial build and on resize
updateCameraOffsetAndRebuild();
$(window).on('resize', updateCameraOffsetAndRebuild);
// compute front index from currentRotation
function frontIndexFromRotation(rot){
const normalized = ((-rot % 360) + 360) % 360;
let idx = Math.round(normalized / angleStep) % n;
idx = (idx + n) % n;
return idx;
}
// update visuals (classes + transforms) - called per-frame
function updateVisuals(){
menu3d.find('.menu-item').each(function(){
const i = Number($(this).attr('data-i'));
const baseAngle = i * angleStep;
const eff = baseAngle + currentRotation; // degrees
const effRad = eff * Math.PI/180;
const maxTilt = 20; // stronger tilt to hug globe
const curveDeg = Math.sin(effRad) * maxTilt;
// placement: rotate around Y, push out by radius, center, billboard and tilt
const t = `rotateY(${eff}deg) translateZ(${radius}px) translateX(-50%) translateY(-50%) rotateY(${-eff}deg) rotateX(${curveDeg}deg)`;
$(this).css('transform', t);
});
const front = frontIndexFromRotation(currentRotation);
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');
});
// update detail display
$('#detailMain').text(capitalize(mainLabels[front]));
if(mainLabels[front] === 'games') $('#detailSub').text(gamesList[currentGameIndex]); else $('#detailSub').text('');
}
// immediate visuals update without waiting for next RAF (useful after build)
function updateVisualsImmediate(){
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);
});
const front = frontIndexFromRotation(currentRotation);
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('');
}
// animation loop: continuous spin + update visuals
// default: rotate left by default speed (slider controls)
let lastRAF = performance.now();
function rafLoop(ts){
const dt = (ts - lastRAF) / 1000;
lastRAF = ts;
// integrate rotation
currentRotation += speedDeg * dt;
// keep in 0..360
currentRotation = ((currentRotation % 360) + 360) % 360;
updateVisuals();
requestAnimationFrame(rafLoop);
}
// set initial rotation so index 0 (Games) is front
currentRotation = 0;
// ensure items exist and visible
buildItems();
updateVisualsImmediate();
requestAnimationFrame(rafLoop);
// interaction: slider to control speed
$('#speedRange').on('input change', function(){
speedDeg = Number($(this).val());
$('#speedVal').text(speedDeg);
});
// snapping helper (animate currentRotation so chosen index becomes front)
function snapToIndex(targetIndex, duration=520){
const targetAngle = -targetIndex * angleStep;
// pick nearest equivalent by adding multiples of 360
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; updateVisuals(); },
complete: function(){ currentRotation = ((target%360)+360)%360; updateVisuals(); }
});
}
// keyboard nav: left/right
function handleLeftRight(isRight){
const front = frontIndexFromRotation(currentRotation);
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); });
$('#starImg').stop(true).animate({opacity:0.94},160).animate({opacity:1},360);
} else {
const target = (front + (isRight?1:-1) + n) % n;
snapToIndex(target, 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},260); }
else if(e.which === 27){ snapToIndex(0,600); }
});
// star PNG support (tries starfish.png then fallback)
const starImg = document.getElementById('starImg');
const defaultStarSVG = `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 = defaultStarSVG; }).catch(_=>{ starImg.src = defaultStarSVG; });
$('#starFile').on('change', function(ev){ const f = ev.target.files && ev.target.files[0]; if(!f) return; const reader = new FileReader(); reader.onload = function(e){ starImg.src = e.target.result; }; reader.readAsDataURL(f); });
// subtle star spin so it feels alive (keeps star behind ribbons)
let starAngle = 0;
(function spinStar(){
starAngle += 0.035;
$('#starImg').css('transform', `rotate(${starAngle}deg) scale(1)`);
requestAnimationFrame(spinStar);
})();
// reset button
$('#resetBtn').on('click', function(){ snapToIndex(0,600); $('#speedRange').val(-6).trigger('change'); });
// 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