Building a Digital Rotary Phone
In a world of touchscreens and haptic feedback, there's something deeply satisfying about the mechanical click-whirrr of a rotary phone. I recently built a digital version of this retro interface for Fezcodex, and I want to take you through the engineering journey—from the trigonometry of the dial to the state management of the call logic.
The Challenge
Building a rotary phone for the web isn't just about displaying an image. It's about capturing the feel of the interaction. You need to:
- Draw a dial with holes.
- Detect user input (mouse or touch).
- Calculate the rotation based on the pointer's position.
- "Drag" the dial realistically.
- Snap back when released.
- Register the dialed number only if the user drags far enough.
Anatomy of the Dial
I broke the RotaryDial component into a few key layers, stacked using CSS absolute positioning:
- The Backplate: This is static. It sits at the bottom and holds the numbers (1, 2, 3... 0) in their correct positions.
- The Rotating Disk: This sits on top of the backplate. It rotates based on user interaction. It contains the "holes".
- The Finger Stop: A static hook at the bottom right (approx 4 o'clock position) that physically stops the dial on a real phone.
The Trigonometry of Angles
The core of this component is converting a mouse position (x, y) into an angle (theta).
const getAngle = (event, center) => { const clientX = event.touches ? event.touches[0].clientX : event.clientX; const clientY = event.touches ? event.touches[0].clientY : event.clientY; const dx = clientX - center.x; const dy = clientY - center.y; // atan2 returns angle in radians, convert to degrees let theta = Math.atan2(dy, dx) * (180 / Math.PI); return theta; };Math.atan2(dy, dx) is perfect here because it handles all quadrants correctly, returning values from -PI to +PI (-180 to +180 degrees).
Why Math.atan2?
You might remember SOH CAH TOA from school. To find an angle given x and y, we typically use the tangent function: tan(θ) = y / x, so θ = atan(y / x).
However, Math.atan() has a fatal flaw for UI interaction: it can't distinguish between quadrants.
- Quadrant 1: x=1, y=1 ->
atan(1/1)= 45° - Quadrant 3: x=-1, y=-1 ->
atan(-1/-1)=atan(1)= 45°
If we used atan, dragging in the bottom-left would behave exactly like dragging in the top-right!
Math.atan2(y, x) solves this by taking both coordinates separately. It checks the signs of x and y to place the angle in the correct full-circle context (-π to +π radians).
We then convert this radian value to degrees: Degrees = Radians * (180 / π)
This gives us a continuous value we can use to map the mouse position directly to the dial's rotation.
The Drag Logic
When a user clicks a specific number's hole, we don't just start rotating from 0. We need to know which hole they grabbed.
Each digit has a "Resting Angle". If the Finger Stop is at 60 degrees, and the holes are spaced 30 degrees apart:
- Digit 1 is at
60 - 30 = 30degrees. - Digit 2 is at
60 - 60 = 0degrees. - ...and so on.
When the user starts dragging, we track the mouse's current angle relative to the center of the dial. The rotation of the dial is then calculated as:
Rotation = CurrentMouseAngle - InitialHoleAngle
Handling the "Wrap Around"
One of the trickiest parts was handling the boundary where angles jump from 180 to -180. For numbers like 9 and 0, the rotation requires dragging past this boundary.
If you just subtract the angles, you might get a jump like 179 -> -179, which looks like a massive reverse rotation. I solved this with a normalization function:
const normalizeDiff = (diff) => { while (diff <= -180) diff += 360; while (diff > 180) diff -= 360; return diff; };However, simply normalizing isn't enough for the long throws (like dragging '0' all the way around). A normalized angle might look like -60 degrees, but we actually mean 300 degrees of positive rotation.
I added logic to detect this "underflow":
// If rotation is negative but adding 360 keeps it within valid range if (newRotation < 0 && (newRotation + 360) <= maxRot + 30) { newRotation += 360; }This ensures that dragging '0' feels continuous, even as it passes the 6 o'clock mark.
State Management vs. Animation
Initially, I used standard React state (useState) for the rotation. This worked, but setState is asynchronous and can feel slightly laggy for high-frequency drag events (60fps).
I switched to Framer Motion's useMotionValue. This allows us to update the rotation value directly without triggering a full React re-render on every pixel of movement. It's buttery smooth.
const rotation = useMotionValue(0); // ... rotation.set(newRotation);When the user releases the dial (handleEnd), we need it to spring back to zero. Framer Motion makes this trivial:
animate(rotation, 0, { type: "spring", stiffness: 200, damping: 20 });The "Call" Logic
The drag logic only handles the visual rotation. To actually dial a number, we check the final rotation when the user releases the mouse.
If abs(CurrentRotation - MaxRotation) < Threshold, we count it as a successful dial.
I connected this to a higher-level RotaryPhonePage component that maintains the string of dialed numbers.
Easter Eggs
Of course, no app is complete without secrets. I hooked up a handleCall function that checks specific number patterns:
- 911: Triggers a red "Connected" state and unlocks "The Emergency" achievement.
- 0: Connects to the Operator.
- Others: Just simulates a call.
Visuals
The dial uses Tailwind CSS for styling. The numbers and holes are positioned using transform: rotate(...) translate(...).
rotate(angle)points the element in the right direction.translate(radius)pushes it out from the center.rotate(-angle)(on the inner text) keeps the numbers upright!
The result is a responsive, interactive, and nostalgic component that was a joy to build. Give it a spin in the Apps section!