Building a Rotary-Dial-Style Circular Slider Animation

Ahmad NegmSep 2026·Ahmad Negm
Animation

Building a Rotary-Dial-Style Circular Slider with Framer Motion in Next.js

I was building the theme picker for EGMenus the part where a restaurant owner scrolls through the available menu themes and picks one and I didn't want a boring horizontal carousel. I wanted something you drag, that spins, and snaps into place. Halfway through building it, I realized I'd basically recreated a rotary telephone dial. Drag it, let go, it springs to the next stop.

Here's how it actually came together, step by step.

The idea

Instead of laying theme cards out in a row, arrange them around a circle. Only one card sits at the top (the "active" one), and dragging left or right rotates the whole wheel so the next or previous card takes its place. A logo sits anchored below the wheel as a visual hub.

Three problems to solve:

  1. How do you position N cards evenly around a circle without them overlapping?
  2. How do you make the whole thing draggable, with a natural spring-back feel?
  3. How do you keep all of this responsive, since the number of cards and the screen width both change the math?

Step 1: Positioning cards around a circle

With N cards, each one needs to sit 360 / N degrees apart. That's the easy part:

tsx

code
const angleOffset = 360 / numItems;

The harder part is the radius. If the cards are wide and packed close together, they'll overlap near the top of the circle. I needed a radius large enough that, given each card's width and the angle between them, adjacent cards never touch. That's a straightforward trig problem a chord-length calculation using the law of sines:

tsx

code
const gap = itemWidth * 0.25; const radius = (itemWidth + gap) / (2 * Math.sin((angleOffset * Math.PI) / 360));

Bigger itemWidth, or a smaller angleOffset (more cards packed into the circle), both push the radius out automatically. No manual tuning per screen size.

To actually place a card, I rotate it to its angle, then push it outward by the radius, pivoting from the top-center of the element:

tsx

code
style={{  transformOrigin: '50% 0%',  transform: `rotate(${angle}deg) translateY(-${radius}px) translateZ(0)`, }}

Rotating around the top-center point and then translating along the (now rotated) Y-axis is what walks each card around the rim of the circle instead of just spinning it in place. translateZ(0) is just there to force GPU acceleration on the transform.

Step 2: Making it draggable

This is where Framer Motion earns its keep. Instead of animating a left/top position, I track a single rotation value for the whole wheel and let every card inherit it:

tsx

code
const rotation = useMotionValue(0); const smoothRotation = useSpring(rotation, { stiffness: 100, damping: 10 });

rotation is the raw value I set directly while the user is dragging. smoothRotation is what actually gets applied to the wheel's rotate style Framer Motion interpolates toward rotation with spring physics, so even instant jumps in rotation come out smooth on screen.

The drag itself uses Framer's pan gesture handlers on the wheel:

tsx

code
onPan={(e, info) => {  if (Math.abs(info.offset.x) > 5) isDraggingRef.current = true;  rotation.set(targetRotation + info.offset.x * 0.15); }}

While the user's finger (or cursor) moves, the wheel loosely follows the horizontal offset scaled down by 0.15 so it feels like you're dragging something with weight, not 1:1 tracking.

Step 3: Snapping to the nearest card

activeIndex tracks which card is currently "up." Its target rotation is just:

tsx

code
const targetRotation = -activeIndex * angleOffset;

Whenever activeIndex changes, an effect pushes the wheel to that target, and the spring handles the actual animation:

tsx

code
useEffect(() => {  rotation.set(targetRotation); }, [activeIndex, angleOffset, rotation]);

The decision to actually change activeIndex happens on release, based on how far or how fast the user swiped:

tsx

code
onPanEnd={(e, info) => {  const swipeThreshold = 50;  if (info.offset.x < -swipeThreshold || info.velocity.x < -500) {  setActiveIndex(prev => prev + 1);  } else if (info.offset.x > swipeThreshold || info.velocity.x > 500) {  setActiveIndex(prev => prev - 1);  } else {  rotation.set(targetRotation);  } }}

A slow, small drag that doesn't cross the threshold just snaps back to where it was same as a rotary dial that doesn't quite make it around.

Step 4: Telling a drag from a click

Each card is also a link to a live preview of that theme. Without extra handling, every drag would end in an unwanted navigation, because the pointer technically ends on top of a link. I used a ref to flag "this gesture was a drag, not a tap":

tsx

code
const isDraggingRef = useRef(false);  onPanStart={() => { isDraggingRef.current = false; }} onPan={(e, info) => {  if (Math.abs(info.offset.x) > 5) isDraggingRef.current = true;  // ... }}

Then every link's onClick checks that ref before letting the navigation happen:

tsx

code
onClick={(e) => {  if (isDraggingRef.current) e.preventDefault(); }}

A short setTimeout resets the flag after onPanEnd, so a genuine tap right after a drag still works normally.

Step 5: Making it responsive without hardcoding breakpoints

itemWidth is state, recalculated on resize:

tsx

code
useEffect(() => {  const updateSize = () => setItemWidth(Math.min(1000, window.innerWidth));  updateSize();  window.addEventListener('resize', updateSize);  return () => window.removeEventListener('resize', updateSize); }, []);

Because gap, radius, and every card's estimated height are all derived from itemWidth, resizing the window recalculates the entire geometry of the wheel automatically no separate math for mobile vs. desktop. The container's total height (containerHeight) is worked out the same way: estimate the card's height from its aspect ratio, add space for the logo hub underneath, add a little padding, done.

Step 6: Anchoring the logo hub

The logo sits absolutely positioned below the arc, at a height calculated from the card height above it:

tsx

code
<div  className="absolute left-1/2 -translate-x-1/2 z-20 pointer-events-none"  style={{ top: logoTopPosition }} >  <AnimatedLogo className="relative w-48 h-48 md:w-96 md:h-96 lg:w-[450px] lg:h-[450px]" /> </div>

Visually, it reads as the hub the wheel spins around even though structurally it's just another absolutely positioned element doing its own thing underneath.

Where it landed

This is what's currently live in EGMenus as the theme picker. The nice part about solving the positioning with trigonometry instead of fixed values is that it doesn't care how many themes I add later the wheel just redistributes itself.

If you're building something similar, the two ideas worth stealing are: drive the whole animation off a single useMotionValue + useSpring pair instead of animating each card separately, and let the geometry (radius, angles, container height) be calculated, not guessed per breakpoint.

Comments