Created for an episode of Component Carousel. Spin that wheel!
A Pen by Ryan Mulligan on CodePen.
Created for an episode of Component Carousel. Spin that wheel!
A Pen by Ryan Mulligan on CodePen.
<div class="deal-wheel"> | |
<ul class="spinner"></ul> | |
<figure class="cap"> | |
<!-- Grim reaper SVG import --> | |
[[[https://codepen.io/hexagoncircle/pen/vYxKLOa]]] | |
</figure> | |
<div class="ticker"></div> | |
<button class="btn-spin">Spin the wheel</button> | |
</div> |
/** | |
* Prize data will space out evenly on the deal wheel based on the amount of items available. | |
* @param text [string] name of the prize | |
* @param color [string] background color of the prize | |
* @param reaction ['resting' | 'dancing' | 'laughing' | 'shocked'] Sets the reaper's animated reaction | |
*/ | |
const prizes = [ | |
{ | |
text: "10% Off Sticker Price", | |
color: "hsl(197 30% 43%)", | |
reaction: "dancing" | |
}, | |
{ | |
text: "Free Car", | |
color: "hsl(173 58% 39%)", | |
reaction: "shocked" | |
}, | |
{ | |
text: "No Money Down", | |
color: "hsl(43 74% 66%)", | |
reaction: "shocked" | |
}, | |
{ | |
text: "Half Off Sticker Price", | |
color: "hsl(27 87% 67%)", | |
reaction: "shocked" | |
}, | |
{ | |
text: "Free DIY Carwash", | |
color: "hsl(12 76% 61%)", | |
reaction: "dancing" | |
}, | |
{ | |
text: "Eternal Damnation", | |
color: "hsl(350 60% 52%)", | |
reaction: "laughing" | |
}, | |
{ | |
text: "Used Travel Mug", | |
color: "hsl(91 43% 54%)", | |
reaction: "laughing" | |
}, | |
{ | |
text: "One Solid Hug", | |
color: "hsl(140 36% 74%)", | |
reaction: "dancing" | |
} | |
]; | |
const wheel = document.querySelector(".deal-wheel"); | |
const spinner = wheel.querySelector(".spinner"); | |
const trigger = wheel.querySelector(".btn-spin"); | |
const ticker = wheel.querySelector(".ticker"); | |
const reaper = wheel.querySelector(".grim-reaper"); | |
const prizeSlice = 360 / prizes.length; | |
const prizeOffset = Math.floor(180 / prizes.length); | |
const spinClass = "is-spinning"; | |
const selectedClass = "selected"; | |
const spinnerStyles = window.getComputedStyle(spinner); | |
let tickerAnim; | |
let rotation = 0; | |
let currentSlice = 0; | |
let prizeNodes; | |
const createPrizeNodes = () => { | |
prizes.forEach(({ text, color, reaction }, i) => { | |
const rotation = ((prizeSlice * i) * -1) - prizeOffset; | |
spinner.insertAdjacentHTML( | |
"beforeend", | |
`<li class="prize" data-reaction=${reaction} style="--rotate: ${rotation}deg"> | |
<span class="text">${text}</span> | |
</li>` | |
); | |
}); | |
}; | |
const createConicGradient = () => { | |
spinner.setAttribute( | |
"style", | |
`background: conic-gradient( | |
from -90deg, | |
${prizes | |
.map(({ color }, i) => `${color} 0 ${(100 / prizes.length) * (prizes.length - i)}%`) | |
.reverse() | |
} | |
);` | |
); | |
}; | |
const setupWheel = () => { | |
createConicGradient(); | |
createPrizeNodes(); | |
prizeNodes = wheel.querySelectorAll(".prize"); | |
}; | |
const spinertia = (min, max) => { | |
min = Math.ceil(min); | |
max = Math.floor(max); | |
return Math.floor(Math.random() * (max - min + 1)) + min; | |
}; | |
const runTickerAnimation = () => { | |
// https://css-tricks.com/get-value-of-css-rotation-through-javascript/ | |
const values = spinnerStyles.transform.split("(")[1].split(")")[0].split(","); | |
const a = values[0]; | |
const b = values[1]; | |
let rad = Math.atan2(b, a); | |
if (rad < 0) rad += (2 * Math.PI); | |
const angle = Math.round(rad * (180 / Math.PI)); | |
const slice = Math.floor(angle / prizeSlice); | |
if (currentSlice !== slice) { | |
ticker.style.animation = "none"; | |
setTimeout(() => ticker.style.animation = null, 10); | |
currentSlice = slice; | |
} | |
tickerAnim = requestAnimationFrame(runTickerAnimation); | |
}; | |
const selectPrize = () => { | |
const selected = Math.floor(rotation / prizeSlice); | |
prizeNodes[selected].classList.add(selectedClass); | |
reaper.dataset.reaction = prizeNodes[selected].dataset.reaction; | |
}; | |
trigger.addEventListener("click", () => { | |
if (reaper.dataset.reaction !== "resting") { | |
reaper.dataset.reaction = "resting"; | |
} | |
trigger.disabled = true; | |
rotation = Math.floor(Math.random() * 360 + spinertia(2000, 5000)); | |
prizeNodes.forEach((prize) => prize.classList.remove(selectedClass)); | |
wheel.classList.add(spinClass); | |
spinner.style.setProperty("--rotate", rotation); | |
ticker.style.animation = "none"; | |
runTickerAnimation(); | |
}); | |
spinner.addEventListener("transitionend", () => { | |
cancelAnimationFrame(tickerAnim); | |
trigger.disabled = false; | |
trigger.focus(); | |
rotation %= 360; | |
selectPrize(); | |
wheel.classList.remove(spinClass); | |
spinner.style.setProperty("--rotate", rotation); | |
}); | |
setupWheel(); |
@import url("https://fonts.googleapis.com/css2?family=Girassol&display=swap"); | |
* { | |
box-sizing: border-box; | |
} | |
html, | |
body { | |
height: 100%; | |
} | |
body { | |
display: grid; | |
place-items: center; | |
overflow: hidden; | |
} | |
.deal-wheel { | |
--size: clamp(250px, 80vmin, 700px); | |
--lg-hs: 0 3%; | |
--lg-stop: 50%; | |
--lg: linear-gradient( | |
hsl(var(--lg-hs) 0%) 0 var(--lg-stop), | |
hsl(var(--lg-hs) 20%) var(--lg-stop) 100% | |
); | |
position: relative; | |
display: grid; | |
grid-gap: calc(var(--size) / 20); | |
align-items: center; | |
grid-template-areas: | |
"spinner" | |
"trigger"; | |
font-family: "Girassol", sans-serif; | |
font-size: calc(var(--size) / 21); | |
line-height: 1; | |
text-transform: lowercase; | |
} | |
.deal-wheel > * { | |
grid-area: spinner; | |
} | |
.deal-wheel .btn-spin { | |
grid-area: trigger; | |
justify-self: center; | |
} | |
.spinner { | |
position: relative; | |
display: grid; | |
align-items: center; | |
grid-template-areas: "spinner"; | |
width: var(--size); | |
height: var(--size); | |
transform: rotate(calc(var(--rotate, 25) * 1deg)); | |
border-radius: 50%; | |
box-shadow: inset 0 0 0 calc(var(--size) / 40) hsl(0deg 0% 0% / 0.06); | |
} | |
.spinner * { | |
grid-area: spinner; | |
} | |
.prize { | |
position: relative; | |
display: flex; | |
align-items: center; | |
padding: 0 calc(var(--size) / 6) 0 calc(var(--size) / 20); | |
width: 50%; | |
height: 50%; | |
transform-origin: center right; | |
transform: rotate(var(--rotate)); | |
user-select: none; | |
} | |
.cap { | |
--cap-size: calc(var(--size) / 4); | |
position: relative; | |
justify-self: center; | |
width: var(--cap-size); | |
height: var(--cap-size); | |
} | |
/* Hide select dropdown from SVG import file */ | |
.cap select { | |
display: none; | |
} | |
.cap svg { | |
width: 100%; | |
} | |
.ticker { | |
position: relative; | |
left: calc(var(--size) / -15); | |
width: calc(var(--size) / 10); | |
height: calc(var(--size) / 20); | |
background: var(--lg); | |
z-index: 1; | |
clip-path: polygon(20% 0, 100% 50%, 20% 100%, 0% 50%); | |
transform-origin: center left; | |
} | |
.btn-spin { | |
color: hsl(0deg 0% 100%); | |
background: var(--lg); | |
border: none; | |
font-family: inherit; | |
font-size: inherit; | |
line-height: inherit; | |
text-transform: inherit; | |
padding: 0.9rem 2rem 1rem; | |
border-radius: 0.25rem; | |
cursor: pointer; | |
transition: opacity 200ms ease-out; | |
} | |
.btn-spin:focus { | |
outline-offset: 2px; | |
} | |
.btn-spin:active { | |
transform: translateY(1px); | |
} | |
.btn-spin:disabled { | |
cursor: progress; | |
opacity: 0.25; | |
} | |
/* Spinning animation */ | |
.is-spinning .spinner { | |
transition: transform 8s cubic-bezier(0.1, -0.01, 0, 1); | |
} | |
.is-spinning .ticker { | |
animation: tick 700ms cubic-bezier(0.34, 1.56, 0.64, 1); | |
} | |
@keyframes tick { | |
40% { | |
transform: rotate(-12deg); | |
} | |
} | |
/* Selected prize animation */ | |
.prize.selected .text { | |
color: white; | |
animation: selected 800ms ease; | |
} | |
@keyframes selected { | |
25% { | |
transform: scale(1.25); | |
text-shadow: 1vmin 1vmin 0 hsla(0 0% 0% / 0.1); | |
} | |
40% { | |
transform: scale(0.92); | |
text-shadow: 0 0 0 hsla(0 0% 0% / 0.2); | |
} | |
60% { | |
transform: scale(1.02); | |
text-shadow: 0.5vmin 0.5vmin 0 hsla(0 0% 0% / 0.1); | |
} | |
75% { | |
transform: scale(0.98); | |
} | |
85% { | |
transform: scale(1); | |
} | |
} |
<link href="https://codepen.io/hexagoncircle/pen/vYxKLOa.css" rel="stylesheet" /> |