Created
March 10, 2026 01:38
-
-
Save EncodeTheCode/bab9bfc2b264bf27be802978a15e27f7 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 — 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