photo of a ferris wheel at sunset
Photo by Max van den Oetelaar on Unsplash

A Rotating Word Wheel Interaction

How to build a scroll-based rotating menu in React.

5 min read

Today we’ll be re-creating an interesting UI interaction seen on the homepage of Suno.ai. When I saw this scroll-based rotating menu, I wondered right away how to build something like it!

The “Word Wheel” (my naming, not theirs) is a unique interaction where sentences are positioned evenly around a circle, and scrolling down the page rotates the circle to bring each sentence into an "active" position.

First, an Example

Before we actually build the component, let’s first run through an example of how to position items around a circular midpoint. Understanding how this works will help us in the later steps of the article.

Starting with the CSS, we're using a combination of the position and transform properties to move the items around the .inner circle.


.container {
  max-width: 600px;
  margin: 100px auto;
  display: flex;
  justify-content: center;
  align-items: center;
}

.inner {
  position: relative;
  width: 50px;
  height: 50px;
  background: #ccc;
  border-radius: 50%;
}

.item {
  position: absolute;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  font-weight: bold;
  font-size: 34px;
}

We also have an outer container with some flexbox properties to hold everything together.

Now for the HTML structure.

The key here is the transform style property applied to the items. For each item, we divide the number of items (ITEMS.length) into 360, then multiply by the index.


return (
  <div class="inner container">
    {ITEMS.map((item, i) => {
      const rotation = (360 / ITEMS.length) * i;

      return (
        <div
          key={i}
          class="item"
          style={{ transform: `rotate(${rotation}deg) translate(50px)` }}
        >
          {item}
        </div>
      );
    })}
  </div>
);

This gives us numbers like 0, 60, 120, etc.

We then apply this number to the rotate function to get the even spacing we’re looking for. The translate(50px) value moves each number to the circumference of the circle.

If we wanted the items to be positioned exactly at the edge of the circle, we’d use 100px for the width and the height of the inner div. In this example though, the items should be outside the circle so we used smaller dimensions. Experiment by adjusting the height/width of the inner div.

Building the Word Wheel

Now that we’ve covered how to evenly space items, let’s start building out the component.

Begin by creating a new component named WordWheel.

We have a state value centerRotation that stores a number, and a ref, wordWheelRef, which is a reference to the "wheel" element itself.


import { useState, useEffect, useRef } from 'react';
import './styles.css';

export const WordWheel = ({ items }) => {
  const [centerRotation, setCenterRotation] = useState(0);
  const wordWheelRef = useRef();

  return ();
}

The component has a single prop named items, which is an array of strings. For the best result, each string should be close to the same length.

Now we have HTML for the component to render with a few containing elements - outer, wordwheel and the centerpoint.


return (
  <div className="outer">
    <div className="wordwheel" ref={wordWheelRef}>
      <div className="centerpoint" style={{ transform: `rotate(-${centerRotation}deg)`}}>
        
      </div>
    </div>
  </div>
);

Within those elements, map over the sentences passed into the component.


<div className="centerpoint" style={{ ... }}>
	{items.map((item, i) => {
	  const itemRotation = (360 / items.length) * i;
	 
	  return (
	    <p
	      key={i}
	      style={{ transform: `rotate(${itemRotation}deg) translate(840px)` }}
	    >
	      {item}
	    </p>
	  );
	})}
</div>

The rotation and positioning of each item is determined by the itemRotation value and should look very familiar from the first example.

Finally, add a useEffect to set a scroll event listener when the component first mounts.


useEffect(() => {
  document.addEventListener('scroll', (e) => {
    const rotationVal = e.target.documentElement.scrollTop;

    setCenterRotation(rotationVal * 0.1);
  });
}, []);

This event gets the current scrollTop position when the page is scrolled and updates the state value with it.

Notice that we multiply the scroll value by 0.1 before updating the state. This slows the rate at which the word wheel can rotate.
Feel free to experiment with different numbers to determine your ideal rotation speed.

CSS Styles

First, make a new file named styles.css.

We’ll start by setting an arbitrary fallback height for the outer container. This provides a default value in case the dynamic calculation of the height can’t be calculated.


.outer {
  /* Arbitrary fallback height in case it cannot be calculated */
  height: 10000px;
}

Then we’ll use some basic flexbox alignment properties on the wordwheel element and set the height of it to be 100vh.


.wordwheel {
  display: flex;
  align-items: center;
  height: 100vh;
}

Next, position the centerpoint of the wheel to be just off the left side of the screen by -150px. We also use a combination of fixed positioning and flexbox alignment to keep everything in place.


.centerpoint {
  position: fixed;
  left: -150px;
  display: flex;
  justify-content: center;
  align-items: center;
}

Each paragraph item in the word wheel is positioned absolutely, with a min-width of 1000px. This keeps each item at a uniform size and prevents the text from wrapping.


.centerpoint p {
  position: absolute;
  min-width: 1000px;
  font-size: 82px;
  color: #9f9f9f;
  transition: color 0.3s ease;
  display: flex;
  align-items: center;
}

Controlling the Height

So far everything appears to be working but depending on how many strings are passed in the items prop, some may be visible more than once depending how far the page has been scrolled, or some may not be visible at all.

We need to make sure that the rotation ends on the last item. This prevents users from being able to rotate the wheel more than 360 degrees and seeing content that was already shown. We also want to be sure this number adjusts dynamically depending on the height of the screen.

First we calculate the rotation position of the last item and store it in lastItemRotationVal.

Then get the height of the wordwheel div using the wordWheelRef.


export const WordWheel = ({ items }) => {
  ...

  const lastItemRotationVal = (360 / items.length) * (items.length - 1); 
  const wheelElHeight = wordWheelRef.current?.offsetHeight;
  const outerHeight = (lastItemRotationVal * 10) + wheelElHeight;

  return (...);
}

Finally, multiply the lastItemRotationVal value by 10 and add it to the wheelElHeight. 10 is somewhat of an arbitrary number but it's effective!

Now apply the outerHeight value to the outer div.


return (
  <div className="outer" style={{ height: `${outerHeight}px` }}>
    ...
  </div>
);

The wheel can’t be scrolled past the last item now!

Adding an Active State

The last thing to do is add an active state.

An item becomes active at the 3 o’clock position of the circle. But a user shouldn’t need to scroll the page precisely to trigger an active state, so we’ll create a bit of ± threshold.

When an item is “active”, its text is a darker color and an orange marker is shown next to it.

All we do is check if centerRotation falls within a range of ±10 degrees from the value of itemRotation.


{items.map((item, i) => {
  ...
  const isActive = centerRotation >= itemRotation - 10 && centerRotation <= itemRotation + 10;

  return (
    <p
	    ...
      className={ isActive ? 'active' : 'inactive' }
    >
      {item}
    </p>
  );
})}

You can expand this threshold by using number higher than 10.

Now that there’s an active class being applied, we have some CSS to add.

First, add a :before pseudo-element to each paragraph, creating a circular marker. It’ll scale from a value of 0 to 1 when active.


.centerpoint p:before {
  content: '';
  display: block;
  position: absolute;
  left: -100px;
  height: 40px;
  width: 40px;
  background: #ee6300;
  border-radius: 50%;
  transform: scale(0);
  transition: transform 0.2s ease;
}

Finally, we update the text color when the active class is added, or if the text is hovered and apply the scale to the pseudo-element.


.centerpoint p.active,
.centerpoint p:hover {
  color: #222;
}

.centerpoint p.active:before {
  transform: scale(1);
}

Tidying Up

To account for smaller screens, you can add a media query with some adjustments to spacing and font sizes so that the content better fits the width of the screen.


@media screen and (max-width: 1200px) {
  .centerpoint {
    left: -210px;
  }

  .centerpoint p {
    font-size: 70px;
  }

  .centerpoint p:before {
    left: -70px;
  }
}

You could also define smaller breakpoints with even further refinements to spacing, if necessary. For the purposes of this demo, the layout is not fully responsive.

Summary

This article is a tutorial on how to build a scroll-based rotating menu in React. We covered how to evenly space items around a circular midpoint and created a rotating “word wheel” interaction that rotates text strings when the page is scrolled.