CSS Trigonometric Functions: Using sin(), cos(), and tan() in Stylesheets
CSS sin(), cos(), and tan() compute angles directly in stylesheets, enabling circular layouts and orbital animations without JavaScript trigonometry.
The Clock Face That Moved Itself
The clock face on the dashboard of the retired ferry had not moved in eleven years, its twelve numbers frozen in a circle that some long-gone developer had hard-coded with magic numbers. Then a single CSS rule replaced the entire script that had been spinning it. The hands began to sweep again. The trigonometric functions sin(), cos(), and tan() bring angle-based computation into CSS, enabling circular layouts, orbital animations, and wave effects without JavaScript. They shipped in all engines by 2023 and reached Baseline widely available status in March 2026. Check caniuse for the latest support picture.
What the Functions Return and Why That Matters
Every trigonometric function in CSS takes an angle or a number and returns a number or an angle. That distinction is where most mistakes live. sin(), cos(), and tan() each accept an , something like 30deg, 0.5turn, or calc(1rad + 45deg). They return a plain between -1 and 1 for sin and cos, and an unbounded number for tan. The inverse functions asin(), acos(), and atan() take a and return an in radians by default. atan2() accepts two numbers and returns the angle whose tangent is the quotient of the first divided by the second. The critical rule: because the forward functions return a unitless number, you must multiply by a length or angle to use them in a length context. The correct pattern is calc(sin(45deg) * 100px), never sin(45deg) alone where a length is expected.
CSS Sin() Cos() Tan() Functions in Circular Layout
Building a Circular Arrangement
Angle-based CSS layout is the first thing to build. It replaces the most tedious script pattern: a loop that sets transform: translate on each element to arrange them around a circle. With custom properties, the math moves into the stylesheet where it belongs. The sample below positions twelve items evenly spaced. A custom property --angle increments per item, and the pair sin() and cos() compute the x and y offsets. The browser's compositor handles the positioning with zero layout cost for transform-only changes.
:root { --radius: 120px; }
.ring {
position: relative;
width: 300px;
height: 300px;
}
.ring li {
position: absolute;
left: 50%;
top: 50%;
width: 40px;
height: 40px;
margin: -20px 0 0 -20px;
--angle: calc(360deg / 12 * var(--i));
transform: translate(
calc(cos(var(--angle)) * var(--radius)),
calc(sin(var(--angle)) * var(--radius))
);
}
.ring li:nth-child(1) { --i: 0; }
.ring li:nth-child(2) { --i: 1; }
.ring li:nth-child(3) { --i: 2; }
.ring li:nth-child(4) { --i: 3; }
.ring li:nth-child(5) { --i: 4; }
.ring li:nth-child(6) { --i: 5; }
.ring li:nth-child(7) { --i: 6; }
.ring li:nth-child(8) { --i: 7; }
.ring li:nth-child(9) { --i: 8; }
.ring li:nth-child(10) { --i: 9; }
.ring li:nth-child(11) { --i: 10; }
.ring li:nth-child(12) { --i: 11; }
Notice the transform uses only translate. That is compositor-only and never triggers layout. The fallback for older browsers is a static grid: the same twelve items placed in a simple flex row, which remains functional though not circular. The @supports guard tests the exact pattern @supports (transform: rotate(calc(sin(1deg) * 1deg))) to gate the circular arrangement.
CSS Animation Trigonometric: The Pendulum Without Keyframes
Animating a Pendulum With a Single Custom Property
Trigonometric motion replaces the old @keyframes approach where you hand-calculated easing values for every frame of a swing. The trick is registering a custom property with @property so the browser can interpolate it as an angle. Then sin() turns that animated angle into a displacement. This sample is a pendulum bob that swings naturally, driven entirely by a registered --swing-angle custom property animating from -30deg to 30deg and back. The animation timeline runs off-main-thread because only transform is animated.
@property --swing-angle {
syntax: '';
inherits: false;
initial-value: 0deg;
}
.pendulum {
width: 4px;
height: 150px;
background: #333;
transform-origin: top center;
animation: swing 2s ease-in-out infinite alternate;
position: relative;
}
.pendulum::after {
content: '';
position: absolute;
bottom: -20px;
left: -18px;
width: 40px;
height: 40px;
border-radius: 50%;
background: #666;
}
@keyframes swing {
from { transform: rotate(calc(sin(var(--swing-angle)) * 30deg)); }
to { transform: rotate(calc(sin(var(--swing-angle)) * 30deg)); }
}
@keyframes swing-angle {
from { --swing-angle: -90deg; }
to { --swing-angle: 90deg; }
}
.pendulum {
animation:
swing-angle 2s ease-in-out infinite alternate,
swing 2s ease-in-out infinite alternate;
}
The first keyframe block for swing is technically redundant because the rotation is driven by the --swing-angle interpolation, but keeping it makes the intent readable. The fallback for older browsers is a static pendulum at a fixed 15deg rotation, which loses the motion but keeps the visual. The @property registration is essential. Without it, the browser treats --swing-angle as a discrete custom property and will not interpolate it smoothly, snapping between frames instead.
Atan2() for Pointer Angles: The Gaze Follower
Making an Eye Follow the Cursor
The atan2() function solves the problem that used to demand a mousemove handler doing trigonometry on every event: finding the angle from an element to the cursor. While asin() and acos() have domain restrictions, their input must be in [-1, 1] or they return NaN, atan2() takes two numbers and returns the angle for any coordinate pair. That is exactly what pointer tracking needs. This sample makes an eye, a circle with a pupil, follow the mouse by computing the angle from the eye's center to the pointer. The script is reduced to setting two custom properties, --dx and --dy. The trigonometry happens in CSS.
.eye {
width: 100px;
height: 100px;
border-radius: 50%;
background: white;
position: relative;
overflow: hidden;
--dx: 0;
--dy: 0;
}
.pupil {
position: absolute;
width: 30px;
height: 30px;
border-radius: 50%;
background: black;
left: 50%;
top: 50%;
margin: -15px 0 0 -15px;
transform: translate(
calc(cos(atan2(var(--dy), var(--dx))) * 20px),
calc(sin(atan2(var(--dy), var(--dx))) * 20px)
);
}
document.addEventListener('mousemove', (e) => {
const eye = document.querySelector('.eye');
const rect = eye.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
eye.style.setProperty('--dx', e.clientX - cx);
eye.style.setProperty('--dy', e.clientY - cy);
});
The script only reads the mouse position and writes two numbers. No angle calculation, no Math.atan2 call, no transform string building. The atan2() function returns the angle in radians, which sin() and cos() consume directly to offset the pupil within the 20px range. For browsers without atan2(), the fallback is a statically centered pupil that does not follow the pointer. That is acceptable for decorative elements and keeps the page functional. The layout cost stays at none because the pupil uses only transform, and the compositor handles the per-frame updates.
CSS Math Functions Trigonometry: Domain and Syntax Rules
CSS trigonometric functions have precise rules about what goes in and what comes out. Violating them silently produces invalid results. The inverse functions have hard bounds: asin() and acos() require input in [-1, 1]. Anything outside returns NaN, which propagates through the calc() expression. The atan() function takes any number and returns an angle in the range (-90deg, 90deg), while atan2(y, x) returns an angle in (-180deg, 180deg] with the correct quadrant determined by the signs of both arguments. All inverse functions return radians by default. To convert, multiply by 180deg/1rad or use calc(atan(1) * 180deg / 3.14159) for an approximation. The forward functions accept any angle unit: deg, rad, grad, turn. The browser normalizes internally, so calc(sin(0.5turn)) equals calc(sin(180deg)).
Trigonometric Functions at a Glance
| Function | Input | Output | Range |
|---|---|---|---|
| sin() | angle | number | -1 to 1 |
| cos() | angle | number | -1 to 1 |
| tan() | angle | number | unbounded |
| asin() | number | angle | -90deg to 90deg |
| acos() | number | angle | 0deg to 180deg |
| atan() | number | angle | -90deg to 90deg |
| atan2() | number, number | angle | -180deg to 180deg |
Frequently Asked Questions
Do I need a polyfill for CSS trigonometric functions?
No. There is no polyfill that retrofits sin() into a browser that lacks it, because the functions are computed inside the CSS engine's calc() pipeline. The fallback is a static pre-calculated value using approximate constants in plain calc(), or a script snippet that sets custom properties. The @supports guard lets you write the circular layout and keep a linear fallback.
How do I convert radians to degrees in CSS?
Multiply by 180deg and divide by 1rad: calc(1rad * 180deg / 1rad) gives 57.2958deg. For a cleaner constant, use calc(atan(1) * 4) which is exactly 180deg because atan(1) returns 45deg worth of radians.
Can I use sin() in a media query?
No. Media queries do not accept calc() expressions with trigonometric functions. They only support plain length comparisons. Use custom properties and container queries instead, or compute the layout in the cascade with @supports.
What happens if asin() gets a value outside [-1, 1]?
The function returns NaN, and that NaN poisons the entire calc() expression it belongs to. The declaration becomes invalid at computed-value time, and the property falls back to its initial value or the previous valid declaration.
Why does my animation with sin() snap instead of smooth?
You are animating a custom property that is not registered with @property. Without registration, the browser treats it as a discrete value and does not interpolate it. Register the property with syntax: '' or '' and inherits: false. The interpolation then works.
Is tan() safe to use for layout?
Yes, but watch for asymptotes: tan(90deg) is infinite, and the result at exactly 90deg is undefined. Use tan() for slope-like or aspect-ratio computations where the angle stays well away from 90deg, or clamp the angle with min() and max().
Do these functions work inside keyframes and transitions?
Yes, when the values are part of a registered custom property. A transition on --angle from 0deg to 360deg with a transform using cos(var(--angle)) * 100px will interpolate the angle and recompute the transform per frame, entirely on the compositor.
The Fallback That Keeps You Honest
Trigonometric functions do not degrade to a polyfill. Your fallback strategy is a simpler arrangement that remains functional, and that is a feature, not a limitation. For the circular ring, the fallback is a flex row of twelve items. For the pendulum, a static rotation. For the gaze follower, a centered pupil. Write the fallback first, then the @supports guard, and the trigonometric enhancement on top. This forces you to think about what the layout is for, not what the math can do. The @supports condition that works reliably across engines since 2023 is @supports (transform: rotate(calc(sin(1deg) * 1deg))). It exercises the full path from constant to angle to multiplication. Do not test for the function name alone. Some older engines parse the declaration but fail to compute it.
What You Should Build Next
Take the circular layout sample and replace the twelve hard-coded :nth-child rules with a single loop generated by your build step. Extend it to a 24-hour dial with minutes. The point is not to avoid scripting entirely. The gaze follower still needs a mousemove listener to feed coordinates. The point is to move the math into CSS where the compositor can manage it. Add the @supports guard to your existing circular or animated components. Replace one scripted position calculation with sin() and cos(). You will notice immediately which parts of your codebase were doing trigonometry that CSS can own.