Skip to content

Commit 6386140

Browse files
committed
Updates
1 parent 113b0da commit 6386140

File tree

64 files changed

+291
-118
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

64 files changed

+291
-118
lines changed

asset-manifest.json

Lines changed: 45 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
{
22
"files": {
3-
"main.css": "/static/css/main.e93e2101.css",
4-
"main.js": "/static/js/main.69b26d6e.js",
3+
"main.css": "/static/css/main.9966ffef.css",
4+
"main.js": "/static/js/main.963a7b81.js",
55
"static/js/8921.11ef1767.chunk.js": "/static/js/8921.11ef1767.chunk.js",
66
"static/js/9815.4260d602.chunk.js": "/static/js/9815.4260d602.chunk.js",
7-
"static/js/536.c38d94d6.chunk.js": "/static/js/536.c38d94d6.chunk.js",
7+
"static/js/536.240f5592.chunk.js": "/static/js/536.240f5592.chunk.js",
88
"static/js/224.550e1655.chunk.js": "/static/js/224.550e1655.chunk.js",
99
"static/js/5678.45a3eefe.chunk.js": "/static/js/5678.45a3eefe.chunk.js",
1010
"static/js/2615.8e3ece48.chunk.js": "/static/js/2615.8e3ece48.chunk.js",
11-
"static/js/1860.b6e9477d.chunk.js": "/static/js/1860.b6e9477d.chunk.js",
12-
"static/js/6703.6dbc965d.chunk.js": "/static/js/6703.6dbc965d.chunk.js",
11+
"static/js/1860.d056ac17.chunk.js": "/static/js/1860.d056ac17.chunk.js",
12+
"static/js/6703.0fa27239.chunk.js": "/static/js/6703.0fa27239.chunk.js",
1313
"static/js/1885.426c9e06.chunk.js": "/static/js/1885.426c9e06.chunk.js",
1414
"static/js/3815.35415090.chunk.js": "/static/js/3815.35415090.chunk.js",
1515
"static/css/285.9d390265.chunk.css": "/static/css/285.9d390265.chunk.css",
@@ -25,44 +25,44 @@
2525
"static/css/2208.9d390265.chunk.css": "/static/css/2208.9d390265.chunk.css",
2626
"static/js/2208.30a6df36.chunk.js": "/static/js/2208.30a6df36.chunk.js",
2727
"static/js/8138.2b482ff4.chunk.js": "/static/js/8138.2b482ff4.chunk.js",
28-
"static/js/634.ab872627.chunk.js": "/static/js/634.ab872627.chunk.js",
28+
"static/js/634.4d72bb2f.chunk.js": "/static/js/634.4d72bb2f.chunk.js",
2929
"static/js/5261.65cfb211.chunk.js": "/static/js/5261.65cfb211.chunk.js",
30-
"static/js/4556.a5d046b2.chunk.js": "/static/js/4556.a5d046b2.chunk.js",
31-
"static/js/5801.3429141f.chunk.js": "/static/js/5801.3429141f.chunk.js",
30+
"static/js/4556.a3b107c6.chunk.js": "/static/js/4556.a3b107c6.chunk.js",
31+
"static/js/5801.1b9cb36b.chunk.js": "/static/js/5801.1b9cb36b.chunk.js",
3232
"static/js/2880.7185a1eb.chunk.js": "/static/js/2880.7185a1eb.chunk.js",
3333
"static/js/7728.cdd772ee.chunk.js": "/static/js/7728.cdd772ee.chunk.js",
34-
"static/js/7314.b49b9d28.chunk.js": "/static/js/7314.b49b9d28.chunk.js",
35-
"static/js/8336.27427f18.chunk.js": "/static/js/8336.27427f18.chunk.js",
34+
"static/js/7314.50211821.chunk.js": "/static/js/7314.50211821.chunk.js",
35+
"static/js/8336.7e369871.chunk.js": "/static/js/8336.7e369871.chunk.js",
3636
"static/js/6313.c68029de.chunk.js": "/static/js/6313.c68029de.chunk.js",
3737
"static/js/4898.9e6e653d.chunk.js": "/static/js/4898.9e6e653d.chunk.js",
38-
"static/js/9164.ab7da16c.chunk.js": "/static/js/9164.ab7da16c.chunk.js",
39-
"static/js/5121.f5abd74b.chunk.js": "/static/js/5121.f5abd74b.chunk.js",
38+
"static/js/9164.54996574.chunk.js": "/static/js/9164.54996574.chunk.js",
39+
"static/js/5121.a5f71069.chunk.js": "/static/js/5121.a5f71069.chunk.js",
4040
"static/css/7712.12024a9c.chunk.css": "/static/css/7712.12024a9c.chunk.css",
41-
"static/js/7712.84fbcddb.chunk.js": "/static/js/7712.84fbcddb.chunk.js",
41+
"static/js/7712.b6a2ed5b.chunk.js": "/static/js/7712.b6a2ed5b.chunk.js",
4242
"static/css/1651.1e2c30b1.chunk.css": "/static/css/1651.1e2c30b1.chunk.css",
43-
"static/js/1651.15db6ef8.chunk.js": "/static/js/1651.15db6ef8.chunk.js",
44-
"static/js/3936.fac5f6ef.chunk.js": "/static/js/3936.fac5f6ef.chunk.js",
45-
"static/js/1054.387690f3.chunk.js": "/static/js/1054.387690f3.chunk.js",
46-
"static/js/1227.201105c6.chunk.js": "/static/js/1227.201105c6.chunk.js",
47-
"static/js/405.457234c3.chunk.js": "/static/js/405.457234c3.chunk.js",
48-
"static/js/2147.c9968970.chunk.js": "/static/js/2147.c9968970.chunk.js",
49-
"static/js/3581.b9087144.chunk.js": "/static/js/3581.b9087144.chunk.js",
50-
"static/js/6833.6af71feb.chunk.js": "/static/js/6833.6af71feb.chunk.js",
51-
"static/js/3798.1a864857.chunk.js": "/static/js/3798.1a864857.chunk.js",
43+
"static/js/1651.09c36504.chunk.js": "/static/js/1651.09c36504.chunk.js",
44+
"static/js/3936.5ef2fb97.chunk.js": "/static/js/3936.5ef2fb97.chunk.js",
45+
"static/js/1054.11eaa59a.chunk.js": "/static/js/1054.11eaa59a.chunk.js",
46+
"static/js/1227.0c5eed87.chunk.js": "/static/js/1227.0c5eed87.chunk.js",
47+
"static/js/405.66e1fe2e.chunk.js": "/static/js/405.66e1fe2e.chunk.js",
48+
"static/js/2147.758896f8.chunk.js": "/static/js/2147.758896f8.chunk.js",
49+
"static/js/3581.a974a55d.chunk.js": "/static/js/3581.a974a55d.chunk.js",
50+
"static/js/6833.ebb028bf.chunk.js": "/static/js/6833.ebb028bf.chunk.js",
51+
"static/js/3798.01ddc581.chunk.js": "/static/js/3798.01ddc581.chunk.js",
5252
"static/js/2951.e76d932d.chunk.js": "/static/js/2951.e76d932d.chunk.js",
5353
"static/js/1845.78d1eb6e.chunk.js": "/static/js/1845.78d1eb6e.chunk.js",
54-
"static/js/3792.efd1df84.chunk.js": "/static/js/3792.efd1df84.chunk.js",
54+
"static/js/3792.940979d8.chunk.js": "/static/js/3792.940979d8.chunk.js",
5555
"static/js/4114.8bb15264.chunk.js": "/static/js/4114.8bb15264.chunk.js",
5656
"static/css/1714.527d164e.chunk.css": "/static/css/1714.527d164e.chunk.css",
57-
"static/js/1714.92d256bd.chunk.js": "/static/js/1714.92d256bd.chunk.js",
57+
"static/js/1714.105ff10d.chunk.js": "/static/js/1714.105ff10d.chunk.js",
5858
"static/css/2516.197c657e.chunk.css": "/static/css/2516.197c657e.chunk.css",
59-
"static/js/2516.b3d00638.chunk.js": "/static/js/2516.b3d00638.chunk.js",
59+
"static/js/2516.a81aa5af.chunk.js": "/static/js/2516.a81aa5af.chunk.js",
6060
"static/css/4818.3d31f1ef.chunk.css": "/static/css/4818.3d31f1ef.chunk.css",
61-
"static/js/4818.f9a77a5a.chunk.js": "/static/js/4818.f9a77a5a.chunk.js",
62-
"static/js/7315.522b1068.chunk.js": "/static/js/7315.522b1068.chunk.js",
63-
"static/js/6265.377fc62a.chunk.js": "/static/js/6265.377fc62a.chunk.js",
64-
"static/js/671.69e75d8e.chunk.js": "/static/js/671.69e75d8e.chunk.js",
65-
"static/js/1245.97f36e87.chunk.js": "/static/js/1245.97f36e87.chunk.js",
61+
"static/js/4818.d2ca30fd.chunk.js": "/static/js/4818.d2ca30fd.chunk.js",
62+
"static/js/7315.4a7aad69.chunk.js": "/static/js/7315.4a7aad69.chunk.js",
63+
"static/js/6265.56b57cde.chunk.js": "/static/js/6265.56b57cde.chunk.js",
64+
"static/js/671.0b0cb14d.chunk.js": "/static/js/671.0b0cb14d.chunk.js",
65+
"static/js/1245.65398109.chunk.js": "/static/js/1245.65398109.chunk.js",
6666
"static/js/5853.e037f2d5.chunk.js": "/static/js/5853.e037f2d5.chunk.js",
6767
"static/js/8505.52a42e2a.chunk.js": "/static/js/8505.52a42e2a.chunk.js",
6868
"static/js/9772.2aadfb92.chunk.js": "/static/js/9772.2aadfb92.chunk.js",
@@ -71,16 +71,16 @@
7171
"static/js/6318.9abc0be1.chunk.js": "/static/js/6318.9abc0be1.chunk.js",
7272
"static/js/3896.322bce8c.chunk.js": "/static/js/3896.322bce8c.chunk.js",
7373
"static/css/2349.f292f0fd.chunk.css": "/static/css/2349.f292f0fd.chunk.css",
74-
"static/js/2349.85e7a61b.chunk.js": "/static/js/2349.85e7a61b.chunk.js",
75-
"static/js/7165.3d89d965.chunk.js": "/static/js/7165.3d89d965.chunk.js",
76-
"static/js/1095.5b733f2c.chunk.js": "/static/js/1095.5b733f2c.chunk.js",
74+
"static/js/2349.ff5dcdfa.chunk.js": "/static/js/2349.ff5dcdfa.chunk.js",
75+
"static/js/7165.6d45d8a6.chunk.js": "/static/js/7165.6d45d8a6.chunk.js",
76+
"static/js/1095.efd97c69.chunk.js": "/static/js/1095.efd97c69.chunk.js",
7777
"static/css/8084.19f4a017.chunk.css": "/static/css/8084.19f4a017.chunk.css",
78-
"static/js/8084.e1961273.chunk.js": "/static/js/8084.e1961273.chunk.js",
79-
"static/js/5018.42a524ec.chunk.js": "/static/js/5018.42a524ec.chunk.js",
80-
"static/js/7668.e783729b.chunk.js": "/static/js/7668.e783729b.chunk.js",
81-
"static/js/6426.632be85c.chunk.js": "/static/js/6426.632be85c.chunk.js",
82-
"static/js/4194.7264c814.chunk.js": "/static/js/4194.7264c814.chunk.js",
83-
"static/js/9524.8508ce85.chunk.js": "/static/js/9524.8508ce85.chunk.js",
78+
"static/js/8084.b303e110.chunk.js": "/static/js/8084.b303e110.chunk.js",
79+
"static/js/5018.d02971c5.chunk.js": "/static/js/5018.d02971c5.chunk.js",
80+
"static/js/7668.b839e83a.chunk.js": "/static/js/7668.b839e83a.chunk.js",
81+
"static/js/6426.98753af3.chunk.js": "/static/js/6426.98753af3.chunk.js",
82+
"static/js/4194.b8958d4f.chunk.js": "/static/js/4194.b8958d4f.chunk.js",
83+
"static/js/9524.ab05dcb4.chunk.js": "/static/js/9524.ab05dcb4.chunk.js",
8484
"static/js/4328.67852bae.chunk.js": "/static/js/4328.67852bae.chunk.js",
8585
"static/js/8898.7f5bf088.chunk.js": "/static/js/8898.7f5bf088.chunk.js",
8686
"static/js/5198.b203165a.chunk.js": "/static/js/5198.b203165a.chunk.js",
@@ -92,20 +92,20 @@
9292
"static/js/9533.28437c01.chunk.js": "/static/js/9533.28437c01.chunk.js",
9393
"static/js/4101.fe515659.chunk.js": "/static/js/4101.fe515659.chunk.js",
9494
"static/js/6706.04823740.chunk.js": "/static/js/6706.04823740.chunk.js",
95-
"static/js/8039.9a0111c0.chunk.js": "/static/js/8039.9a0111c0.chunk.js",
95+
"static/js/8039.b7346402.chunk.js": "/static/js/8039.b7346402.chunk.js",
9696
"static/js/9145.80aca5f3.chunk.js": "/static/js/9145.80aca5f3.chunk.js",
9797
"static/js/1877.7b270b02.chunk.js": "/static/js/1877.7b270b02.chunk.js",
9898
"static/js/259.ea239673.chunk.js": "/static/js/259.ea239673.chunk.js",
9999
"static/js/7692.cbf8528f.chunk.js": "/static/js/7692.cbf8528f.chunk.js",
100100
"static/js/7233.08dc0810.chunk.js": "/static/js/7233.08dc0810.chunk.js",
101-
"static/js/6133.672bbafa.chunk.js": "/static/js/6133.672bbafa.chunk.js",
101+
"static/js/6133.4b55b099.chunk.js": "/static/js/6133.4b55b099.chunk.js",
102102
"static/js/4945.24d64015.chunk.js": "/static/js/4945.24d64015.chunk.js",
103103
"static/js/1649.d02c91a3.chunk.js": "/static/js/1649.d02c91a3.chunk.js",
104104
"static/css/8334.e383744f.chunk.css": "/static/css/8334.e383744f.chunk.css",
105105
"static/js/8334.eeb1e971.chunk.js": "/static/js/8334.eeb1e971.chunk.js",
106106
"static/js/3966.f30ea84b.chunk.js": "/static/js/3966.f30ea84b.chunk.js",
107-
"static/js/7843.fb59989a.chunk.js": "/static/js/7843.fb59989a.chunk.js",
108-
"static/js/7251.cf54ce09.chunk.js": "/static/js/7251.cf54ce09.chunk.js",
107+
"static/js/7843.5546c123.chunk.js": "/static/js/7843.5546c123.chunk.js",
108+
"static/js/7251.280023a8.chunk.js": "/static/js/7251.280023a8.chunk.js",
109109
"static/js/6453.e650ae28.chunk.js": "/static/js/6453.e650ae28.chunk.js",
110110
"static/js/5655.db6c3d04.chunk.js": "/static/js/5655.db6c3d04.chunk.js",
111111
"static/js/3204.2204b28c.chunk.js": "/static/js/3204.2204b28c.chunk.js",
@@ -128,7 +128,7 @@
128128
"index.html": "/index.html"
129129
},
130130
"entrypoints": [
131-
"static/css/main.e93e2101.css",
132-
"static/js/main.69b26d6e.js"
131+
"static/css/main.9966ffef.css",
132+
"static/js/main.963a7b81.js"
133133
]
134134
}
36.6 KB
Loading

index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
<!doctype html><html lang="en" class="dark"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><link rel="icon" type="image/svg+xml" href="/favicon.svg"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><link rel="alternate" type="application/rss+xml" title="Fezcodex RSS Feed" href="/rss.xml"/><meta name="description" content="codex by fezcode..."/><link rel="apple-touch-icon" href="/logo192.png"/><link rel="manifest" href="/manifest.json"/><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet"><link href="https://fonts.googleapis.com/css2?family=Arvo&family=Inter&family=Playfair+Display&display=swap" rel="stylesheet"><title>fezcodex</title><script defer="defer" src="/static/js/main.69b26d6e.js"></script><link href="/static/css/main.e93e2101.css" rel="stylesheet"></head><body class="bg-slate-950"><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
1+
<!doctype html><html lang="en" class="dark"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><link rel="icon" type="image/svg+xml" href="/favicon.svg"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><link rel="alternate" type="application/rss+xml" title="Fezcodex RSS Feed" href="/rss.xml"/><meta name="description" content="codex by fezcode..."/><link rel="apple-touch-icon" href="/logo192.png"/><link rel="manifest" href="/manifest.json"/><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet"><link href="https://fonts.googleapis.com/css2?family=Arvo&family=Inter&family=Playfair+Display&display=swap" rel="stylesheet"><title>fezcodex</title><script defer="defer" src="/static/js/main.963a7b81.js"></script><link href="/static/css/main.9966ffef.css" rel="stylesheet"></head><body class="bg-slate-950"><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
In a world of touchscreens and haptic feedback, there's something deeply satisfying about the mechanical click-whirrr of a rotary phone.
2+
I recently built a digital version of this retro interface for Fezcodex, and I want to take you through the engineering journey—from the
3+
trigonometry of the dial to the state management of the call logic.
4+
5+
## The Challenge
6+
7+
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:
8+
1. Draw a dial with holes.
9+
2. Detect user input (mouse or touch).
10+
3. Calculate the rotation based on the pointer's position.
11+
4. "Drag" the dial realistically.
12+
5. Snap back when released.
13+
6. Register the dialed number only if the user drags far enough.
14+
15+
## Anatomy of the Dial
16+
17+
I broke the `RotaryDial` component into a few key layers, stacked using CSS absolute positioning:
18+
19+
1. **The Backplate**: This is static. It sits at the bottom and holds the numbers (1, 2, 3... 0) in their correct positions.
20+
2. **The Rotating Disk**: This sits on top of the backplate. It rotates based on user interaction. It contains the "holes".
21+
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.
22+
23+
### The Trigonometry of Angles
24+
25+
The core of this component is converting a mouse position (x, y) into an angle (theta).
26+
27+
```javascript
28+
const getAngle = (event, center) => {
29+
const clientX = event.touches ? event.touches[0].clientX : event.clientX;
30+
const clientY = event.touches ? event.touches[0].clientY : event.clientY;
31+
32+
const dx = clientX - center.x;
33+
const dy = clientY - center.y;
34+
// atan2 returns angle in radians, convert to degrees
35+
let theta = Math.atan2(dy, dx) * (180 / Math.PI);
36+
return theta;
37+
};
38+
```
39+
40+
`Math.atan2(dy, dx)` is perfect here because it handles all quadrants correctly, returning values from -PI to +PI (-180 to +180 degrees).
41+
42+
### Why `Math.atan2`?
43+
44+
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)`.
45+
46+
However, `Math.atan()` has a fatal flaw for UI interaction: it can't distinguish between quadrants.
47+
* Quadrant 1: x=1, y=1 -> `atan(1/1)` = 45°
48+
* Quadrant 3: x=-1, y=-1 -> `atan(-1/-1)` = `atan(1)` = 45°
49+
50+
If we used `atan`, dragging in the bottom-left would behave exactly like dragging in the top-right!
51+
52+
`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).
53+
54+
We then convert this radian value to degrees:
55+
`Degrees = Radians * (180 / π)`
56+
57+
This gives us a continuous value we can use to map the mouse position directly to the dial's rotation.
58+
59+
## The Drag Logic
60+
61+
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.
62+
63+
Each digit has a "Resting Angle". If the Finger Stop is at 60 degrees, and the holes are spaced 30 degrees apart:
64+
* Digit 1 is at `60 - 30 = 30` degrees.
65+
* Digit 2 is at `60 - 60 = 0` degrees.
66+
* ...and so on.
67+
68+
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:
69+
70+
`Rotation = CurrentMouseAngle - InitialHoleAngle`
71+
72+
### Handling the "Wrap Around"
73+
74+
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.
75+
76+
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:
77+
78+
```javascript
79+
const normalizeDiff = (diff) => {
80+
while (diff <= -180) diff += 360;
81+
while (diff > 180) diff -= 360;
82+
return diff;
83+
};
84+
```
85+
86+
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.
87+
88+
I added logic to detect this "underflow":
89+
90+
```javascript
91+
// If rotation is negative but adding 360 keeps it within valid range
92+
if (newRotation < 0 && (newRotation + 360) <= maxRot + 30) {
93+
newRotation += 360;
94+
}
95+
```
96+
97+
This ensures that dragging '0' feels continuous, even as it passes the 6 o'clock mark.
98+
99+
## State Management vs. Animation
100+
101+
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).
102+
103+
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.
104+
105+
```javascript
106+
const rotation = useMotionValue(0);
107+
// ...
108+
rotation.set(newRotation);
109+
```
110+
111+
When the user releases the dial (`handleEnd`), we need it to spring back to zero. Framer Motion makes this trivial:
112+
113+
```javascript
114+
animate(rotation, 0, {
115+
type: "spring",
116+
stiffness: 200,
117+
damping: 20
118+
});
119+
```
120+
121+
## The "Call" Logic
122+
123+
The drag logic only handles the visual rotation. To actually dial a number, we check the final rotation when the user releases the mouse.
124+
125+
If `abs(CurrentRotation - MaxRotation) < Threshold`, we count it as a successful dial.
126+
127+
I connected this to a higher-level `RotaryPhonePage` component that maintains the string of dialed numbers.
128+
129+
### Easter Eggs
130+
131+
Of course, no app is complete without secrets. I hooked up a `handleCall` function that checks specific number patterns:
132+
* **911**: Triggers a red "Connected" state and unlocks "The Emergency" achievement.
133+
* **0**: Connects to the Operator.
134+
* **Others**: Just simulates a call.
135+
136+
## Visuals
137+
138+
The dial uses Tailwind CSS for styling. The numbers and holes are positioned using `transform: rotate(...) translate(...)`.
139+
* `rotate(angle)` points the element in the right direction.
140+
* `translate(radius)` pushes it out from the center.
141+
* `rotate(-angle)` (on the inner text) keeps the numbers upright!
142+
143+
The result is a responsive, interactive, and nostalgic component that was a joy to build. Give it a spin in the **Apps** section!

0 commit comments

Comments
 (0)