Wheel of Fortune with CSS
Mads Stoumann

Mads Stoumann @madsstoumann

About: I'm a tech director, web developer, graphic designer, musician, blogger, comicbook-geek, LEGO-collector, food lover … as well as husband and father!

Location:
Copenhagen, Denmark
Joined:
Nov 16, 2020

Wheel of Fortune with CSS

Publish Date: May 23 '24
185 23

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

Comments 23 total

  • Temani Afif
    Temani AfifMay 23, 2024

    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

    • Mads Stoumann
      Mads StoumannMay 23, 2024

      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.

  • Debajyati Dey
    Debajyati DeyMay 24, 2024

    wholesome wheel! :)
    Beautiful project!

  • Eckehard
    EckehardMay 26, 2024

    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?

    • LcsGa
      LcsGaAug 23, 2024

      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.

  • Jocom Vag
    Jocom VagMay 30, 2024

    Always here for an interesting CSS project. Kudos.

  • Martin Prihoda
    Martin PrihodaNov 10, 2024

    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.

    • Martin Prihoda
      Martin PrihodaNov 10, 2024

      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
      • Mads Stoumann
        Mads StoumannNov 13, 2024

        Cool!

      • Mads Stoumann
        Mads StoumannNov 13, 2024
        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
    • Mads Stoumann
      Mads StoumannNov 12, 2024

      Please share — I need to think about it too!

  • Troy Forsyth
    Troy ForsythDec 1, 2024

    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?

  • Vladyslav Dev
    Vladyslav DevDec 12, 2024

    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!

    • Mads Stoumann
      Mads StoumannDec 12, 2024

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

  • Samuel
    SamuelJan 19, 2025

    Thank you for this amazing article! Everything works flawlessly, as long as it's more than 3 items. For anything less than three, I get some very weird behaviors (these are 1 item, 2 items, and 3 items):

    Image description

    I am trying to find a fix for these, although I suspect it will have to be in js instead of css. I will come back and let you know my solution.

    • Mads Stoumann
      Mads StoumannJan 20, 2025

      Weird — let me know your findings, otherwise I can look into it in the weekend.

      • Samuel
        SamuelJan 26, 2025

        I found a solution, but it's not mathematical in nature, it's kinda brute-force, especially the clip-path on the 3-item spinner. I tried all sorts of math for the clip-paths, but none of it worked close to well, and if you think about the problem, there really isn't a need for complex maths for one or two items.

        NOTE: I added a data-itemCount property on the UL element to make this easier.

        .wheel-of-fortune {
          [...]
          ul {
            [...]
            & li {
             [...]
            }
            &[data-itemCount="1"] {
              li {
                aspect-ratio: 1/1;
                display: block;
                rotate: 0;
                clip-path: none;
                width: 100cqi;
              }
            }
            &[data-itemCount="2"] {
              li {
                height: 100cqi;
                clip-path: none;
              }
            }
            &[data-itemCount="3"] {
              li {
                aspect-ratio: 1 / 2;
                clip-path: polygon(-35% -100%, 100% 50%, 0% 145%);
              }
            }
          }
        }
        
        Enter fullscreen mode Exit fullscreen mode

        As another "optimization," I switched out the correct answer logic because it wasn't working well for me when the spinner landed on or near boundaries. I calculate it like a spinner would - by position.

        NOTE: my spinner element has a header for a title, so I use the header to find the correct value at the position.

                const spinnerElement = document.querySelector('.spinner-with-header');
                const headerElement = spinnerElement.querySelector('.header');
            function getResultsAtTop() {
                if (!spinnerElement) return null;
        
                const rect = headerElement.getBoundingClientRect();
                const centerX = rect.left + rect.width / 2;
                const topY = rect.top + rect.height + 30; // 30 pixels below the header
                const element = document.elementFromPoint(centerX, topY);
                if (element?.parentElement !== spinnerElement) return null;
                return element?.textContent?.trim() || null;
            }
        
        
        Enter fullscreen mode Exit fullscreen mode

        I have a full customizable svelte componenent at github.com/skamansam/ultimate-spin.... The companion site is at rudeboy.dev/ultimate-spinner/

        • Samuel
          SamuelJan 26, 2025

          Here is the codepen forked from the one above, with my modifications in it -

Add comment