DEV Community

Cover image for Wheel of Fortune with CSS
Mads Stoumann
Mads Stoumann

Posted on • Edited on

Wheel of Fortune with CSS

A "Wheel of Fortune" component just popped up in my feed. I always spin, but never win! Anyway, this type of component is often built with <canvas>, so I thought I'd write a tutorial on how to make it in CSS. For the interactivity, you still have to use JavaScript.

Here's what we'll be building:

Wheel Of Fortune

The markup

For the wedges, we'll be using a simple list:

<ul class="wheel-of-fortune">
  <li>$1000</li>
  <li>$2000</li>
  <li>$3000</li>
  <li>$4000</li>
  <li>$5000</li>
  <li>$6000</li>
  <li>$7000</li>
  <li>$8000</li>
  <li>$9000</li>
  <li>$10000</li>
  <li>$11000</li>
  <li>$12000</li>
</ul>
Enter fullscreen mode Exit fullscreen mode

OK, so we have a list of numbers. Now, let's set some initial styles:

:where(.ui-wheel-of-fortune) {
  --_items: 12;
  all: unset;
  aspect-ratio: 1 / 1;
  background: crimson;
  container-type: inline-size;
  direction: ltr;
  display: grid;
  place-content: center start;
}
Enter fullscreen mode Exit fullscreen mode

First is a variable we'll be using to control the amount of items. As the list has 12 items, we set --_items: 12;.

I set the container-type so we can use container-query units (more on that later), then a grid with content placed "left center". This gives us:

Initial

OK, doesn't look like much, let's look into the wedges:

li {
  align-content: center;
  background: deepskyblue;
  display: grid;
  font-size: 5cqi;
  grid-area: 1 / -1;
  list-style: none;
  padding-left: 1ch;
  transform-origin: center right;
  width: 50cqi;
}
Enter fullscreen mode Exit fullscreen mode

Instead of position: absolute we "stack" all the <li> in the same place in the grid using grid-area: 1 / -1. We set the transform-origin to center right, meaning we'll rotate the wedge around that axis.

So, now we have:

With items

Because all the elements are stacked, we can only see the last.

Let's do something about that. First, we'll add an index variable to each wedge:

li {
  &:nth-of-type(1) { --_idx: 1; }
  &:nth-of-type(2) { --_idx: 2; }
  &:nth-of-type(3) { --_idx: 3; }
  &:nth-of-type(4) { --_idx: 4; }
  &:nth-of-type(5) { --_idx: 5; }
  /* etc. */
}
Enter fullscreen mode Exit fullscreen mode

With that we only need to add one more line of CSS:

li {
  rotate: calc(360deg / var(--_items) * calc(var(--_idx) - 1));
}
Enter fullscreen mode Exit fullscreen mode

With rotate

Getting there! Let's use the same variables to create some color variations:

li {
  background: hsl(calc(360deg / var(--_items) *
  calc(var(--_idx))), 100%, 75%);
}
Enter fullscreen mode Exit fullscreen mode

Color Variations


A Slice of π

For the height of the wedges we need the circumference of the circle divided by the amount of items. As you might recall from school, the circumference of a circle is:

C=2πr
Enter fullscreen mode Exit fullscreen mode

Because we're using container-units, the radius is 50cqi, so the formula we need in CSS is:

li {
  height: calc((2 * pi * 50cqi) / var(--_items));
}
Enter fullscreen mode Exit fullscreen mode

Isn't it just cool that we have pi in CSS now?!

With pi for height

Now, let's add a simple clip-path to each wedge. We'll start at the top left corner, move to the right center, then back to left bottom:

li {
  clip-path: polygon(0% 0%, 100% 50%, 0% 100%);
}
Enter fullscreen mode Exit fullscreen mode

With clip-path

Let's deduct a little from the edges:

li {
  clip-path: polygon(0% -2%, 100% 50%, 0% 102%);
}
Enter fullscreen mode Exit fullscreen mode

Not sure, if there's a mathematical correct way to do this?

Anyway, now we just need to add border-radius: 50% to the wrapper:

With border-radius

Hmm, not good. Let's use a clip-path instead, with inset and round:

.wheel-of-fortune {
  clip-path: inset(0 0 0 0 round 50%);
}
Enter fullscreen mode Exit fullscreen mode

Much better:

Wheel Of Fortune, Final

And because we used container-units for the wedges and the font-size, it's fully responsive!


Make it spin

Now, let's add a spin-<button> (see CSS in code-example below) and trigger a spin using JavaScript:

function wheelOfFortune(selector) {
  const node = document.querySelector(selector);
  if (!node) return;

  const spin = node.querySelector('button');
  const wheel = node.querySelector('ul');
  let animation;
  let previousEndDegree = 0;

  spin.addEventListener('click', () => {
    if (animation) {
      animation.cancel(); // Reset the animation if it already exists
    }

    const randomAdditionalDegrees = Math.random() * 360 + 1800;
    const newEndDegree = previousEndDegree + randomAdditionalDegrees;

    animation = wheel.animate([
      { transform: `rotate(${previousEndDegree}deg)` },
      { transform: `rotate(${newEndDegree}deg)` }
    ], {
      duration: 4000,
      direction: 'normal',
      easing: 'cubic-bezier(0.440, -0.205, 0.000, 1.130)',
      fill: 'forwards',
      iterations: 1
    });

    previousEndDegree = newEndDegree;
  });
}
Enter fullscreen mode Exit fullscreen mode

Instead of adding and removing a css-class and updating a @property with a new rotation-angle, I opted for the simplest solution: The Web Animations API!

Full code is here:

UPDATE: The shape-master, Temani Atif, has provided a much more elegant way to create the wedges using tan and aspect-ratio (see comments below).


More ideas

I encourage you to play around with other styles! Maybe add a dotted border?

Dotted border

Top comments (17)

Collapse
 
afif profile image
Temani Afif

The height calculation is actually not correct. You need to consider the polygon shape around the circle to find the correct height (the circumscribed polygon)

It's equal to height: calc(2*50cqi*tan(180deg/var(--_items))); that you can simplify by setting the ratio aspect-ratio: 1/calc(2*tan(180deg/var(--_items))); to avoid using the width value twice.

With this you won't have issue when you apply the clip-path

Collapse
 
madsstoumann profile image
Mads Stoumann • Edited

AH, perfect — thank you! You truly are the master of CSS Shapes. I've added an update, and will use your input for the follow-up article with spinning.

Collapse
 
vladyslav_dev_1c0ce7bcc6c profile image
Vladyslav Dev

I really like your implementation because all libs create a wheel using canvas but i need the one to be highly customizable. Please help me to add logic to spin the wheel to the predefined (from backend or mocked) sector!

Collapse
 
madsstoumann profile image
Mads Stoumann

You just need to set newEndDegree manually, matching your predefined destination.

Collapse
 
martin_prihoda_2e16335cab profile image
Martin Prihoda

Has anyone solved the return value of the winning field? For example index for li tag. I have something, but the results are +- one position.

Collapse
 
martin_prihoda_2e16335cab profile image
Martin Prihoda

I think it’s resolved ( Link on Codepen.io ).

// --------------------------------------------
// append code for getting index of wheel spim
// --------------------------------------------
const rangeAngles = [
  {index:  0, from: 345, to: 360},
  {index:  0, from:   0, to:  15},
  {index:  1, from:  15, to:  45},
  {index:  2, from:  45, to:  75},
  {index:  3, from:  75, to: 105},
  {index:  4, from: 105, to: 135},
  {index:  5, from: 135, to: 165},
  {index:  6, from: 165, to: 195},
  {index:  7, from: 195, to: 225},
  {index:  8, from: 225, to: 255},
  {index:  9, from: 255, to: 285},
  {index: 10, from: 285, to: 315},
  {index: 11, from: 315, to: 345},
];

// position at zero level
const calculateZeroAngle = (finalAngle) => {
  let zeroAngle = 360 - finalAngle + 90;
  zeroAngle = ((zeroAngle % 360) + 360) % 360;
  zeroAngle = Math.round(zeroAngle * 10) / 10;
  return zeroAngle;
};

// Save the final degree to determine the winning segment
animation.onfinish = async () => {
  const finalAngle = ((newEndDegree % 360) + 360) % 360;
  const zeroAngle = calculateZeroAngle( finalAngle );
  const indexfOfWinner = rangeAngles.find( a => a.from < zeroAngle && a.to >= zeroAngle ).index
  alert( `Index of Winner: ${indexfOfWinner}` )
};
Enter fullscreen mode Exit fullscreen mode
Collapse
 
madsstoumann profile image
Mads Stoumann
const normalizeAngle = (finalAngle) => {
  return (360 - finalAngle + 90) % 360;
};

const items = wheel.children.length;
const segment = 360 / items;
const offset = 15;

animation.onfinish = () => {
  const finalAngle = newEndDegree % 360;
  const normalizedAngle = normalizeAngle(finalAngle);
  const winner = Math.floor(((normalizedAngle + offset) % 360) / segment);
  console.log(wheel.children[winner].textContent);
};
Enter fullscreen mode Exit fullscreen mode
Collapse
 
madsstoumann profile image
Mads Stoumann

Cool!

Collapse
 
madsstoumann profile image
Mads Stoumann

Please share — I need to think about it too!

Collapse
 
jocomvag profile image
Jocom Vag

Always here for an interesting CSS project. Kudos.

Collapse
 
madsstoumann profile image
Mads Stoumann

Thanks!

Collapse
 
ddebajyati profile image
Debajyati Dey

wholesome wheel! :)
Beautiful project!

Collapse
 
madsstoumann profile image
Mads Stoumann

Thank you!

Collapse
 
troy_forsyth_e0b0c56bfbf0 profile image
Troy Forsyth

So I found this posting a few days ago. I used the code pen and am trying to make this into a dynamically loading setup. Everything is fine with the wedges until you get below 4 slices. At 3 and less it breaks. I feel like it's something to do with the aspect ration of the LI tags.

Image description

Any thoughts?

Collapse
 
efpage profile image
Eckehard

I suppose this would be easier done in pure Javasript using CSS only where It's appropriate (e.g. the animation). Is it really worth the effort doing anything in CSS?

Collapse
 
lcsga profile image
LcsGa

I don't think that'd be easier in javascript. Whenever you have something visual, the right tool for that is CSS. When it's easier in pure JS, this usually mean that you lack knowledge with CSS.

Collapse
 
efpage profile image
Eckehard

Oh, have fun to rebuild the [solarsystem][dev.to/cookiemonsterdev/solar-syst...] with pure CSS. There are good reasons the S in
CSS comes from "style", not from graphics...