Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

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

Select an option

Save EncodeTheCode/96b75e7de51ef395c696b48121f03dfb 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</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;
}
html,body{
height:100%;
margin:0;
background:#00121a;
font-family:"Trebuchet MS",Arial,sans-serif;
overflow:hidden;
}
#bgCanvas{
position:fixed;
inset:0;
width:100%;
height:100%;
z-index:0;
}
.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;
}
.menu-item{
position:absolute;
left:50%;
top:50%;
transform-origin:center;
transform-style:preserve-3d;
transition:transform .4s cubic-bezier(.22,.95,.3,1);
}
svg.ribbon{
width:760px;
height:260px;
overflow:visible;
display:block;
}
svg.ribbon text{
font-size:var(--label-size);
font-weight:900;
paint-order:stroke fill;
stroke:black;
stroke-width:2;
}
svg.ribbon text tspan{
fill:#f8ff8a;
}
.menu-item.front{opacity:1;z-index:120}
.menu-item.side{opacity:.45}
.menu-item.back{opacity:.2}
.star-wrap{
position:absolute;
left:50%;
top:50%;
transform:translate(-50%,-50%);
width:420px;
height:420px;
z-index:40;
}
#starImg{
max-width:100%;
}
.detail{
position:absolute;
left:50%;
bottom:6%;
transform:translateX(-50%);
color:#eaffc8;
font-size:20px;
z-index:160;
}
</style>
</head>
<body>
<canvas id="bgCanvas"></canvas>
<div class="stage">
<div class="ui">
<div class="menu-wrapper">
<div id="menu3d" class="menu3d"></div>
<div class="star-wrap">
<img id="starImg"/>
</div>
<div class="detail">
<div id="detailMain">Games</div>
<div id="detailSub"></div>
</div>
</div>
</div>
</div>
<script>
/* background */
(function(){
const canvas=document.getElementById("bgCanvas")
const ctx=canvas.getContext("2d")
let W=canvas.width=innerWidth
let H=canvas.height=innerHeight
const blobs=[]
for(let i=0;i<30;i++){
blobs.push({
x:Math.random()*W,
y:Math.random()*H,
r:120+Math.random()*180,
vx:(Math.random()*2-1)*0.2,
vy:(Math.random()*2-1)*0.2
})
}
function draw(){
ctx.clearRect(0,0,W,H)
ctx.globalCompositeOperation="lighter"
ctx.filter="blur(25px)"
for(const b of blobs){
ctx.fillStyle="rgba(40,180,200,0.25)"
ctx.beginPath()
ctx.ellipse(b.x,b.y,b.r,b.r*.8,0,0,Math.PI*2)
ctx.fill()
}
ctx.filter="none"
}
function step(){
for(const b of blobs){
b.x+=b.vx
b.y+=b.vy
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)
}
step()
})()
/* menu */
$(function(){
const mainLabels=["games","video","music","memory","network"]
const subMenus={
video:["Display Mode","Aspect Ratio","Color Settings"],
memory:["Memory Card Slot 1","Memory Card Slot 2","Delete Save"]
}
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
let currentRotation=0
let speedDeg=-6
let menuOpen=false
let currentGameIndex=0
let radius=380
function createRibbonSVG(id,label){
const outR=radius*.9
const dotSpace=25
const dotX=outR+dotSpace
return `
<svg class="ribbon" viewBox="-500 -200 1000 400">
<path d="
M -300 -40
Q 0 -80
300 -40
L 300 40
Q 0 80
-300 40
Z
" fill="#9ddc2f"/>
<text x="0" y="10" text-anchor="middle">
<tspan>${label}</tspan>
</text>
<circle cx="${dotX}" cy="0" r="16" fill="#9ddc2f"/>
</svg>
`
}
function buildItems(){
menu3d.empty()
for(let i=0;i<n;i++){
const label=mainLabels[i]
const item=$(`<div class="menu-item"></div>`)
item.attr("data-i",i)
item.html(createRibbonSVG(i,label))
menu3d.append(item)
}
}
function frontIndexFromRotation(rot){
const normalized=(( -rot %360)+360)%360
return Math.round(normalized/angleStep)%n
}
function updateVisuals(){
menu3d.find(".menu-item").each(function(){
const i=Number($(this).attr("data-i"))
const angle=i*angleStep+currentRotation
const rad=angle*Math.PI/180
const curve=Math.sin(rad)*18
const depth=(Math.cos(rad)+1)/2
const scale=.8+depth*.4
const transform=`
translate(-50%,-50%)
rotateY(${angle}deg)
translateZ(${radius}px)
rotateY(${-angle}deg)
rotateX(${curve}deg)
scale(${scale})
`
$(this).css("transform",transform)
})
const front=frontIndexFromRotation(currentRotation)
menu3d.find(".menu-item").each(function(){
const i=$(this).data("i")
$(this).removeClass("front side back")
if(i===front)$(this).addClass("front")
else if(Math.abs(i-front)==1)$(this).addClass("side")
else $(this).addClass("back")
})
const label=mainLabels[front]
$("#detailMain").text(label)
if(label==="games")
$("#detailSub").text(gamesList[currentGameIndex])
else if(subMenus[label])
$("#detailSub").text(subMenus[label][0])
else
$("#detailSub").text("")
}
function rafLoop(){
if(!menuOpen)
currentRotation+=speedDeg*.016
updateVisuals()
requestAnimationFrame(rafLoop)
}
$(window).on("keydown",function(e){
if(e.which==37){
currentRotation+=angleStep
}
if(e.which==39){
currentRotation-=angleStep
}
if(e.which==13){
menuOpen=true
}
if(e.which==27){
menuOpen=false
}
})
buildItems()
rafLoop()
})
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment