Building a Rotary-Dial-Style Circular Slider 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:
- How do you position N cards evenly around a circle without them overlapping?
- How do you make the whole thing draggable, with a natural spring-back feel?
- 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
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
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
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
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
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
const targetRotation = -activeIndex * angleOffset;Whenever activeIndex changes, an effect pushes the wheel to that target, and the spring handles the actual animation:
tsx
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
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
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
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
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
<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.