Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

Save EncodeTheCode/fa06aba58b4cd8300754a060c4f082ac 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>PS1 Demo-One — Curved 3D Ribbon Menu (fixed text + 3D bend)</title>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<style>
:root{
--menu-radius: 380px;
--label-size: 48px;
--dot-size: 32px;
--ribbon-thickness: 72px;
--front-boost: 48px; /* how much the front ribbon comes forward */
}
html,body{ height:100%; margin:0; background:#00121a; font-family: "Trebuchet MS", Arial, sans-serif; overflow:hidden; -webkit-font-smoothing:antialiased; -moz-osx-font-smoothing:grayscale; }
/* pearl background canvas */
#bgCanvas{ position:fixed; inset:0; width:100%; height:100%; z-index:0; background:linear-gradient(#042833,#082a40 30%, #042a3d 80%); display:block; }
.stage{ position:relative; width:100%; height:100%; display:flex; align-items:center; justify-content:center; perspective:1200px; z-index:2; }
.ui{ width:92%; max-width:1280px; height:86%; display:flex; align-items:center; justify-content:center; position:relative; }
.menu-wrapper{ width:920px; height:520px; position:relative; transform-style:preserve-3d; }
.menu3d{ position:absolute; inset:0; transform-style:preserve-3d; z-index:50; pointer-events:none; } /* keep on top */
.menu-item{ position:absolute; left:50%; top:50%; transform-origin:center center; pointer-events:none; backface-visibility:visible; transform-style:preserve-3d; transition: transform 420ms cubic-bezier(.22,.95,.3,1), opacity 260ms; will-change:transform,opacity; display:block;}
/* SVG ribbons centered at 0,0 */
svg.ribbon{ width:760px; height:260px; overflow:visible; display:block; pointer-events:none; }
svg.ribbon .ribbon-shape{ transition: opacity 200ms; filter: drop-shadow(0 12px 16px rgba(0,0,0,0.55)); }
/* very legible text: fill + stroke + paint-order so stroke sits behind fill */
svg.ribbon text { font-family: "Trebuchet MS", Arial, sans-serif; font-weight:900; font-size:var(--label-size); text-anchor:middle; dominant-baseline:middle; paint-order: stroke fill; stroke: rgba(0,0,0,0.9); stroke-width:2.2; }
svg.ribbon text tspan { fill: #f8ff8a; } /* bright yellow/green fill for strong contrast */
svg.ribbon .dot { transition: transform 220ms, opacity 220ms; filter: drop-shadow(0 8px 10px rgba(0,0,0,0.5)); }
/* depth states */
.menu-item.front{ opacity:1; z-index:120; }
.menu-item.side{ opacity:0.42; z-index:70; }
.menu-item.back{ opacity:0.18; z-index:30; filter:brightness(.85); }
/* center star - sits visually at center (behind ribbons in z-order) */
.star-wrap{ position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); width:420px; height:420px; z-index:40; pointer-events:none; 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 26px rgba(0,0,0,0.55)); opacity:0.98; }
/* controls */
.controls{ position:fixed; right:16px; top:16px; z-index:150; 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; display:block; margin-top:6px; }
.controls label{ display:block; font-weight:700; margin-bottom:6px; }
.detail{ position:absolute; left:50%; bottom:6%; transform:translateX(-50%); z-index:160; color:#eaffc8; text-shadow: 0 2px 8px rgba(0,0,0,0.6); font-weight:700; font-size:20px; pointer-events:none; }
.help{ position:fixed; left:16px; bottom:16px; z-index:150; color:rgba(255,255,255,0.78); background:rgba(0,0,0,0.24); padding:8px 10px; border-radius:8px; font-size:12px; pointer-events:auto; }
@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">
<div class="menu3d" id="menu3d"></div>
<div class="star-wrap" id="starWrap" aria-hidden="true">
<img id="starImg" src="" alt="starfish"/>
</div>
<div class="detail" id="detail">
<div id="detailMain">Games</div>
<div id="detailSub" style="margin-left:14px; opacity:0.95; font-weight:800; color:#c6ffd8"></div>
</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 Left / Right — jump previous/next. <br/>
Continuous spin always runs (adjust slider). <br/>
When <strong>Games</strong> is front, the sublabel cycles games on repeated Left/Right.
</div>
<script>
/* ---------------------------
Pearl noise background (same algorithm but exposed variables tuned)
--------------------------- */
(function(){
const canvas = document.getElementById('bgCanvas');
const 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 = [];
const BLOB_BASE = Math.max(160, Math.round(Math.max(W,H) * 0.12));
for(let i=0;i<BLOBS;i++){
blobs.push({
x: Math.random()*W,
y: Math.random()*H,
r: BLOB_BASE * (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.44
});
}
let perf = 0;
function draw(){
ctx.clearRect(0,0,W,H);
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.filter = 'blur(24px)';
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.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);
})();
/* ---------------------------
Curved ribbon menu implementation (fixed text + 3D bend)
--------------------------- */
$(function(){
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 = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--menu-radius')) || 380;
let ribbonThickness = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--ribbon-thickness')) || 72;
let currentRotation = 0;
let speedDeg = Number($('#speedRange').val()); $('#speedVal').text(speedDeg);
let lastTS = performance.now();
let currentGameIndex = 0;
// Helper: create ribbon SVG (curved pill). text uses textLength to fit arc.
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
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(' ');
// text center arc
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);
const gradId = `grad_${id}_${Math.round(Math.random()*9999)}`;
// viewBox extents
const margin = Math.ceil(ribbonThickness*1.6 + 40);
const extent = Math.ceil(outR + margin);
const viewBox = `${-extent} ${-extent} ${extent*2} ${extent*2}`;
// measure approx text length (approx glyph width * letters) so we can set textLength and avoid overflow
// rough estimate: character width = 0.62 * fontSize
const fontSize = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--label-size')) || 48;
const approxCharW = fontSize * 0.62;
const textSize = Math.max(1, labelText.length * approxCharW);
// build SVG
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="#e8ff7a"/>
<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" lengthAdjust="spacingAndGlyphs" textLength="${Math.min(textSize, midR*1.5)}">
<tspan>${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 menu items (SVG ribbons)
function buildItems(){
menu3d.empty();
for(let i=0;i<n;i++){
const label = mainLabels[i];
const arc = Math.min(100, Math.max(28, Math.round(label.length * 7.4)));
const $it = $(`<div class="menu-item" data-i="${i}" data-arc="${arc}"></div>`);
$it.html(createRibbonSVG(i, label, arc, radius));
menu3d.append($it);
}
// ensure initial visuals
updateVisualsImmediate();
}
// update camera/radius and rebuild ribbons so arcs match radius
function recalcAndBuild(){
const wrapperW = $('.menu-wrapper').width();
radius = Math.max(220, Math.round(wrapperW * 0.44));
ribbonThickness = Math.max(46, Math.round(radius * 0.18));
document.documentElement.style.setProperty('--ribbon-thickness', ribbonThickness + 'px');
buildItems();
}
$(window).on('resize', recalcAndBuild);
// compute front index
function frontIndexFromRotation(rot){
const normalized = ((-rot % 360)+360)%360;
let idx = Math.round(normalized / angleStep) % n;
idx = (idx + n) % n;
return idx;
}
// immediate update (no smoothing) used 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;
// tilt: how much the label tilts up/down along the sphere; use sine so extremes tilt most
const maxTilt = 20;
const curveDeg = Math.sin(effRad) * maxTilt;
// depth scaling (makes front bigger)
const depthFactor = (Math.cos(effRad)+1)/2; // 0 at side/back, 1 at front
const extraFront = (i === frontIndexFromRotation(currentRotation)) ? parseInt(getComputedStyle(document.documentElement).getPropertyValue('--front-boost')) : 0;
// place on ring; note: we DO NOT billboard here (we purposely keep ribbon oriented to surface)
const t = `rotateY(${eff}deg) translateZ(${radius + extraFront}px) translateX(-50%) translateY(-50%) rotateX(${curveDeg}deg) scale(${0.78 + 0.45*depthFactor})`;
$(this).css('transform', t);
});
// classes & detail
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('');
}
// per-frame update (smooth continuous rotation)
function updateVisuals(){
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 maxTilt = 20;
const curveDeg = Math.sin(effRad) * maxTilt;
const depthFactor = (Math.cos(effRad)+1)/2;
// bring the very front item slightly more forward to mimic Demo One pop
const frontIndex = frontIndexFromRotation(currentRotation);
const extraFront = (i === frontIndex) ? parseInt(getComputedStyle(document.documentElement).getPropertyValue('--front-boost')) : 0;
// positioned around center star; do not billboard — keep the ribbon oriented on the sphere surface
const scale = (0.78 + 0.45*depthFactor);
const t = `rotateY(${eff}deg) translateZ(${radius + extraFront}px) translateX(-50%) translateY(-50%) rotateX(${curveDeg}deg) scale(${scale})`;
$(this).css('transform', t);
});
// classes and detail text update
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');
});
if(lastFront !== front){
lastFront = front;
$('#detailMain').text(capitalize(mainLabels[front]));
if(mainLabels[front] === 'games') $('#detailSub').text(gamesList[currentGameIndex]); else $('#detailSub').text('');
}
}
// animation loop
let lastFront = -1;
function rafLoop(ts){
const dt = (ts - lastTS) / 1000;
lastTS = ts;
currentRotation += speedDeg * dt;
currentRotation = ((currentRotation % 360)+360)%360;
updateVisuals();
requestAnimationFrame(rafLoop);
}
// snapping helper to animate rotation to a specific index
function snapToIndex(targetIndex, duration=560){
const targetAngle = -targetIndex * 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; }
});
}
// keyboard nav & games cycling
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.92},150).animate({opacity:1},380);
} else {
const targetIdx = (front + (isRight?1:-1) + n) % n;
snapToIndex(targetIdx, 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); }
});
// controls
$('#speedRange').on('input change', function(){ speedDeg = Number($(this).val()); $('#speedVal').text(speedDeg); });
$('#resetBtn').on('click', function(){ snapToIndex(0,600); $('#speedRange').val(-6).trigger('change'); });
// image star loading (tries starfish.png then fallback)
const starImg = document.getElementById('starImg');
const fallbackSVG = `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=fallbackSVG; }).catch(_=>{ starImg.src=fallbackSVG; });
$('#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);
});
// gentle center star spin (keeps behind ribbons visually)
(function spinStar(){
let a = 0;
(function loop(){ a += 0.028; $('#starImg').css('transform', `rotate(${a}deg) scale(1)`); requestAnimationFrame(loop); })();
})();
// initial build + start
recalcAndBuild();
lastTS = performance.now();
requestAnimationFrame(rafLoop);
// 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