Created
March 10, 2026 01:41
-
-
Save EncodeTheCode/ccfd18b9ef9e4aa3d7d043486d1aedab 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" /> | |
| <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