My maths teacher sat me down at fifteen and told me, kindly but firmly, that a technology career probably wasn’t for me. I was consistently failing algebra. The artistic path made more sense.
She was right about the art part. Wrong about where it leads.
Design school. Motion. Front-end. The kind of sideways career nobody plans in advance. And here I am, a designer who ended up writing code, using trigonometry daily to animate and position things on the web. The subject I failed is now the one I find most satisfying to apply.
CSS has had sin(), cos(), and atan2() since 2023. And once you see the pattern (circles, spirals, arcs, elements that track your cursor) it stops feeling like maths and starts feeling like design decisions with coordinates.
My teacher would find this deeply ironic.
The math in one sentence
To place a point on a circle of radius r at angle θ:
cos gives you the horizontal component, sin gives you the vertical. Both return values between -1 and 1; multiply by the radius to get actual coordinates.
CSS sin() and cos() take a CSS angle; any valid angle unit works: deg, rad, turn.
That formula is everything. The rest of this article is just choosing different angles.
Placing items in a circle
The trick is CSS custom properties. Register an --angle per element, feed it through sin() and cos(), and use the result to offset translate:
.orbit {
position: relative;
width: 200px;
height: 200px;
}
.orbit-dot {
--x: calc(cos(var(--angle)) * var(--r));
--y: calc(sin(var(--angle)) * var(--r));
position: absolute;
top: 50%;
left: 50%;
width: 16px;
height: 16px;
border-radius: 50%;
translate: calc(var(--x) - 50%) calc(var(--y) - 50%);
}
<div class="orbit">
<div class="orbit-dot" style="--angle: 0deg; --r: 90px;"></div>
<div class="orbit-dot" style="--angle: 45deg; --r: 90px;"></div>
<div class="orbit-dot" style="--angle: 90deg; --r: 90px;"></div>
<!-- etc. -->
</div>
--x: calc(cos(var(--angle)) * var(--r)): CSS evaluates cos() at parse time, multiplies by the radius, and you get a pixel offset. Apply it with translate relative to the center of the container.
The - 50% in translate: calc(var(--x) - 50%) calc(var(--y) - 50%) centers each dot on its position rather than its top-left corner.
Eight elements in a circle. No JavaScript. The first time I got this working I said “wait, that’s it?” out loud.
Static, though. --angle is already there. What happens if you spin the container?
Animating the angle
Since --angle is a plain custom property (not a @property registered one), you can’t directly animate it with CSS transitions. But you can animate rotate separately and compose the two transforms:
.rotating-orbit {
position: relative;
width: 200px;
height: 200px;
animation: orbit-spin 6s linear infinite;
}
.rotating-dot {
--angle: calc(var(--i) / var(--total) * 1turn);
--x: calc(cos(var(--angle)) * var(--r));
--y: calc(sin(var(--angle)) * var(--r));
position: absolute;
top: 50%;
left: 50%;
width: 14px;
height: 14px;
border-radius: 50%;
translate: calc(var(--x) - 50%) calc(var(--y) - 50%);
/* counter-rotate so dots stay upright */
animation: orbit-spin 6s linear infinite reverse;
}
@keyframes orbit-spin {
to { rotate: 1turn; }
}
The container rotates forward. Each dot counter-rotates (reverse) at the same speed, so they stay visually upright while orbiting. Two CSS animations, zero JavaScript.
1turn is the same as 360deg. I prefer it here because the intent is clearer.
This is the pattern behind every premium loading spinner you’ve admired in design tools and native apps. You’ve built this with
setIntervala dozen times. You never had to.
(Will Bamberg’s Coder’s Block post goes deep on this pattern for tilted elliptical orbits, worth reading if you want the full picture.)
Continuous rotation is one thing. But what if you want the dots to spring to their positions on hover? You’d need to transition --angle directly, and that’s where the rotate workaround breaks down. The browser doesn’t know --angle is an angle; it can’t interpolate it.
Animating angles with @property
Register --angle and the browser gets a type. Transitions start working:
@property --angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
.property-dot {
--target: calc(var(--i) / var(--total) * 1turn);
--x: calc(cos(var(--angle)) * var(--r));
--y: calc(sin(var(--angle)) * var(--r));
position: absolute;
top: 50%;
left: 50%;
width: 14px;
height: 14px;
border-radius: 50%;
translate: calc(var(--x) - 50%) calc(var(--y) - 50%);
transition: --angle 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
transition-delay: calc(var(--i) * 50ms);
}
.property-ring:hover .property-dot {
--angle: var(--target);
}
Without @property, the browser has no type for --angle and can’t interpolate between values; transitions silently do nothing. With syntax: '<angle>', the browser knows how to interpolate, and the dots smoothly spring from 0deg to their target positions.
inherits: false ensures each element gets its own independent --angle, so the transition-delay stagger works correctly. If inherits were true, a parent’s --angle would bleed into children and the delays would collapse.
The first time I hit this I spent an hour debugging a transition that produced no errors, no warnings, and did absolutely nothing. One line of @property fixed it. The browser was silently ignoring an interpolation it didn’t understand. That’s the kind of bug that makes you feel like you’re going mad.
Notice the transition-delay: calc(var(--i) * 50ms). That stagger comes from the same index already driving position. --i is already doing two things. It can do three.
Staggered entrance
The same logic applies to entrance animations. --i drives animation-delay just as naturally as it drove position:
@keyframes dot-pop {
from { scale: 0; opacity: 0; }
}
.stagger-dot {
--angle: calc(var(--i) / var(--total) * 1turn);
--x: calc(cos(var(--angle)) * var(--r));
--y: calc(sin(var(--angle)) * var(--r));
position: absolute;
top: 50%;
left: 50%;
translate: calc(var(--x) - 50%) calc(var(--y) - 50%);
animation: dot-pop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both;
animation-delay: calc(var(--i) * 80ms);
}
animation-delay: calc(var(--i) * 80ms): each dot waits 80ms longer than the previous one. No JavaScript, no setTimeout loop, no manual delay list. The index variable you already had for positioning does the animation work too.
That choreography is three lines of CSS.
One thing that’s been constant through all of this: the radius. Fixed at whatever --r you set. Unfix it and the shape changes entirely.
Spiral layout
Let --r grow with --i and you get a spiral. A simple Archimedean spiral: r = a + b × θ:
.spiral {
position: relative;
width: 240px;
height: 240px;
}
.spiral-dot {
--angle: calc(var(--i) * 30deg);
--r: calc(8px + var(--i) * 4px); /* radius grows with index */
--x: calc(cos(var(--angle)) * var(--r));
--y: calc(sin(var(--angle)) * var(--r));
position: absolute;
top: 50%;
left: 50%;
width: calc(4px + var(--i) * 0.3px); /* size also grows */
height: calc(4px + var(--i) * 0.3px);
border-radius: 50%;
translate: calc(var(--x) - 50%) calc(var(--y) - 50%);
background: hsl(calc(var(--i) * 11), 80%, 60%);
opacity: calc(0.4 + var(--i) * 0.02);
}
--r: calc(8px + var(--i) * 4px): radius starts at 8px and grows by 4px per step. Every 12 dots is one full rotation (12 × 30° = 360°), so the spiral makes a new ring every 12 elements.
Change 30deg to a smaller step for a denser coil. Change 4px for a tighter or looser spiral. The math is the same; the numbers are just design decisions.
Everything so far has been full circles. Most UI patterns aren’t. Radial menus, context menus, bottom sheets: they occupy a slice. All you need is a start angle and an end angle.
Arc navigation
Distribute items between two angles. The formula handles the rest:
.arc-item {
--range: calc(var(--end) - var(--start));
--angle: calc(var(--start) + var(--i) / (var(--total) - 1) * var(--range));
--x: calc(cos(var(--angle)) * var(--r));
--y: calc(sin(var(--angle)) * var(--r));
position: absolute;
top: 50%;
left: 50%;
translate: calc(var(--x) - 50%) calc(var(--y) - 50%);
}
--range: calc(var(--end) - var(--start)): the total angular span. Divide by --total - 1 to get the step between items (so the first item sits exactly at --start and the last at --end).
Change --start and --end to reshape the arc without touching anything else. The math updates.
This is the Figma context menu pattern. The iOS share sheet. The radial picker in any good design tool. They all distribute items along an arc. Now you know the two CSS lines that do it.
All of this has been forward math: give it an angle, get a position. atan2 runs it backwards.
atan2: pointing at things
You know the position. You want the angle. atan2(y, x) is how you make elements point at a target: arrows, tooltips, line connectors.
.atan2-arrow {
--angle: calc(var(--i) / var(--total) * 1turn);
--x: calc(cos(var(--angle)) * var(--r));
--y: calc(sin(var(--angle)) * var(--r));
/* atan2 of the vector pointing back to center */
--face: atan2(calc(-1 * var(--y)), calc(-1 * var(--x)));
position: absolute;
top: 50%;
left: 50%;
translate: calc(var(--x) - 50%) calc(var(--y) - 50%);
rotate: var(--face);
}
atan2(-y, -x) is the angle of the vector pointing back to the origin; negating both components flips the direction. Apply it to rotate and the element faces inward. Flip the signs and it faces outward.
The target here is the origin. Aim it at the cursor instead:
.cursor-arrow {
--angle: calc(var(--i) / var(--total) * 1turn);
/* element's absolute position within the 280×280 container */
--ex: calc(140px + cos(var(--angle)) * var(--r));
--ey: calc(140px + sin(var(--angle)) * var(--r));
/* vector from element to cursor; CSS computes the angle */
--face: atan2(
calc(var(--my, 140px) - var(--ey)),
calc(var(--mx, 140px) - var(--ex))
);
position: absolute;
top: 50%;
left: 50%;
translate: calc(cos(var(--angle)) * var(--r) - 50%)
calc(sin(var(--angle)) * var(--r) - 50%);
rotate: var(--face);
}
// JS only passes coordinates; CSS handles the geometry
demo.addEventListener('mousemove', e => {
const r = demo.getBoundingClientRect();
demo.style.setProperty('--mx', `${e.clientX - r.left}px`);
demo.style.setProperty('--my', `${e.clientY - r.top}px`);
});
JS feeds two numbers. CSS computes twelve angles. That’s the right division of labour: JavaScript knows where the cursor is; CSS knows what to do with it.
The 140px default for --mx and --my means the arrows face inward when the cursor hasn’t entered the demo yet.
I’ve shown this to designers who don’t write code and they assume it’s some complex library. It’s one mousemove listener and twelve atan2() calls in a stylesheet.
The pattern is always the same: --angle in, cos and sin out, offset with translate. The same --i that drives position drives color, delay, and timing. Reach for @property any time you want to animate a custom property — without a type, the browser can’t interpolate and the transition silently does nothing.
| Feature | Chrome | Firefox | Safari |
|---|---|---|---|
sin(), cos(), atan2() | 111 | 108 | 15.4 |
@property | 85 | 128 | 16.4 |
No flags, no polyfills. Baseline 2023.
I spent years thinking the artistic path and the technical one were different worlds. They’re not. They share the same coordinates. My teacher told me to give up on tech and follow what I was drawn to instead. That accidental redirection is exactly what got me here. Trust the instinct. The path figures itself out.