TIER FORGE IS ONLINE: CONSTRUCT AND VISUALIZE RANKED DATA SETS WITH DRAG-AND-DROP PRECISION. ACCESS AT /APPS/TIER-FORGE.

See Tier Forge
Back to IntelSOURCE: dev

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:

  1. Draw a dial with holes.
  2. Detect user input (mouse or touch).
  3. Calculate the rotation based on the pointer's position.
  4. "Drag" the dial realistically.
  5. Snap back when released.
  6. 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:

  1. The Backplate: This is static. It sits at the bottom and holds the numbers (1, 2, 3... 0) in their correct positions.
  2. The Rotating Disk: This sits on top of the backplate. It rotates based on user interaction. It contains the "holes".
  3. 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).

DATA_NODE: javascript
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 = 30 degrees.
  • Digit 2 is at 60 - 60 = 0 degrees.
  • ...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:

DATA_NODE: javascript
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":

DATA_NODE: javascript
// 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.

DATA_NODE: javascript
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:

DATA_NODE: javascript
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!

// INTEL_SPECIFICATIONS

Dated02/12/2025
Process_Time6 Min
Categorydev

// SERIES_DATA