Math in CSS: Emulating abs(), sign(), round(), and mod() Today

The CSS Values and Units Level 4 spec defines a rich set of mathematical functions — including abs(), sign(), round(), and mod() — but browser support has not caught up yet. That doesn't mean you have to wait. With a few clever combinations of already-supported CSS features, you can compute equivalent values today.

Note that some of these techniques depend on registering custom properties via @property, which currently limits them to Chromium-based browsers.

Computing Absolute Value

The max() function is supported in all modern browsers, and it gives us a straightforward way to compute an absolute value. For a custom property --a, we take the maximum between the value and its additive inverse:

--abs: max(var(--a), -1*var(--a));

If --a is positive, its inverse -1 * var(--a) is negative, so max() returns the original value. If --a is negative, the inverse is positive, and that is what max() returns.

Deriving the Sign

Since the sign of a number is that number divided by its absolute value, we can compute it once --abs is available:

--abs: max(var(--a), -1*var(--a));
--sign: calc(var(--a)/var(--abs));

This works only when --a is unitless, since CSS calc() doesn't allow division by a value with a unit. Also, when --a is 0, the division becomes invalid, so we must register --sign with an initial value of 0:

@property --sign {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false /* or true depending on context */
}

Simply setting --sign: 0 in a regular CSS rule won't be enough; without @property registration, the computed calc() value will override it.

For integer values, there is a simpler alternative that avoids Houdini entirely. Using clamp() with bounds of -1 and 1:

--sign: clamp(-1, var(--a), 1);

For any negative integer, the value hits the lower bound of -1; for positive integers, it hits 1; and for 0, it returns 0. This doesn't require registration, but it only works for unitless values that are either 0 or at least 1 in magnitude. A subunitary value like -.05 would incorrectly return -.05.

To handle subunitary values, we can divide by a very small limit value to scale --a up into a superunitary range before clamping:

--lim: .000001;
--sign: clamp(-1*var(--lim), var(--a), var(--lim));

An alternative suggested by Temani Afif multiplies --a by a very large number to achieve the same effect:

--sign: clamp(-1, var(--a)*10000, 1);

Rounding, Floor and Ceiling

The trick for rounding relies on registering the target variable as an <integer>, which forces the browser to round whatever value is assigned:

@property --round {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false /* or true depending on context */
}

.my-elem { --round: var(--a); }

From there, floor and ceiling are just a matter of shifting by .5 before the rounding kicks in:

@property --floor {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false /* or true depending on context */
}

@property --ceil {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false /* or true depending on context */
}

.my-elem {
  --floor: calc(var(--a) - .5);
  --ceil: calc(var(--a) + .5)
}

Modulo via Floor

With --floor available, we can compute an integer quotient and then derive the modulo. Both operands must again be unitless:

@property --floor {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false /* or true depending on context */
}

.my-elem {
  --floor: calc(var(--a)/var(--b) - .5);
  --mod: calc(var(--a) - var(--b)*var(--floor))
}

Use Case: Symmetrical Animation Delays

One of the most practical applications is creating symmetrical animation-delay values. Consider a row of items inside a container, each with an index --i passed via a style attribute:

- let n = 16;

.wrap(style=`--n: ${n}`)
  - for(let i = 0; i < n; i++)
    .item(style=`--i: ${i}`)

After some basic layout styles, we can compute the middle index --m, then the absolute value of the distance from the middle for each item:

--m: calc(.5*(var(--n) - 1));

Since --abs ranges from 0 (middle item) to --m (end items), dividing by --m always yields a value in [0, 1]. Multiplying that by the animation duration gives each item a delay between 0s and a full duration:

--abs: max(var(--m) - var(--i), var(--i) - var(--m));
animation: a $t calc(var(--abs)/var(--m)*#{$t}) infinite backwards;
animation-name: grow, melt;

Two approaches handle what happens before the animation starts. Setting animation-fill-mode: backwards keeps items at the 0% keyframe styles until their delay elapses. Alternatively, subtract a full animation duration from all delays, forcing them into negative territory so every item is already mid-animation on page load:

--abs: max(var(--m) - var(--i), var(--i) - var(--m));
animation: a $t calc((var(--abs)/var(--m) - 1)*#{$t}) infinite;
animation-name: grow, melt;

This same principle scales to two dimensions. For an 8×8 grid, we compute middle indices along each axis — --m, then the absolute differences for columns --abs-i and rows --abs-j. Each ratio is in [0, 1], so their sum falls in [0, 2]; dividing by 2 brings it back to [0, 1]:

animation-delay: calc(.5*(var(--abs-i)/var(--m) + var(--abs-j)/var(--m))*#{$t});

You can also factor out the denominator for a cleaner formula:

animation-delay: calc(.5*(var(--abs-i) + var(--abs-j))/var(--m)*#{$t});

For alternating animations that run through two iterations, you would let the delays span two cycles instead of halving the sum, and subtract 2 to keep delays negative:

Grid wave: pulsing triangles (live demo)

Beyond delays, the same index-distance pattern drives other properties. For hover and focus interactions, the absolute difference between a navigation link's index and a currently-selected index can be used to highlight the selected item and differentiate the rest:

--abs: Max(var(--k) - var(--idx), var(--idx) - var(--k));
--not-sel: Min(1, var(--abs));
--sel: calc(1 - var(--not-sel));

Here --sel and --not-sel are mutually exclusive integers that always sum to 1. Clicking a link then triggers transitions whose delays depend on the same absolute distance, producing a symmetrical outward cascade:

transition: transform 1s calc(var(--abs)*.05s);

Use Case: Conditional Styling with Sign

The sign function shines when you need different styles for items before versus after a selected one. With a set of radio buttons, each label gets an index --i, and the selected option's index is stored as --k on the body.

Setting background-size: 300% creates three vertical stripes. The sign of the difference --i minus --k determines which stripe is visible:

--sgn: clamp(-1, var(--i) - var(--k), 1);
background: 
  linear-gradient(90deg, 
      nth($c, 1) 33.333%, 
      nth($c, 2) 0 66.667%, 
      nth($c, 3) 0) 
    calc(50%*(1 + var(--sgn)))/ 300%

When --sgn is -1, the background position resolves to 0% (showing the first stripe); when 0, it's 50% (middle stripe); and when 1, it's 100% (last stripe). The same concept extends to pseudo-elements — a moving bar that slides to the side closest to the selected item:

/* relevant styles */
label {
  --sgn: clamp(-1, var(--k) - var(--i), 1);
  
  &::before {
    transform: translate(calc(var(--sgn)*-.5*#{$pad}))
  }
  &::after {
    transform: translate(calc(var(--sgn)*(100% - #{$pad})))
  }
}

Sign also helps position effects relative to a grid's center. A radial-gradient that shrinks per cell can have its coordinates offset depending on whether the cell is left or right of the middle:

Sinking feeling (live demo)

In a 3D sphere of baubles, both the size of each bauble (via absolute distance from the pole) and the rotation direction of a spiral (via sign of that distance) derive from the same index math:

No perspective (live demo)

Use Case: Time and Number Formatting

Floring and modulo make it possible to format values as mm:ss — for example storing seconds in a custom property --val, then displaying minutes and remaining seconds via a counter trick in a pseudo-element:

@property --min {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false;
}

code {
  --min: calc(var(--val)/60 - .5);
  --sec: calc(var(--val) - var(--min)*60);
  counter-reset: min var(--min) sec var(--sec);
  
  &::after {
    /* so we get the time formatted as 02:09 */
    content: 
      counter(min, decimal-leading-zero) ':' 
      counter(sec, decimal-leading-zero);
  }
}

However, a zero value exposes a rounding quirk: 0/60 computes to 0, but subtracting .5 and rounding gives -1, not 0. The fix is to slightly adjust the minute calculation:

--min: max(0, var(--val)/60 - .5);

The same formatting technique extends to hours, and since counter values must be integers, the modulo approach also handles displaying decimals of range slider values:

Screenshot showing a styled slider with a tooltip above the thumb indicating the elapsed time formatted as mm:ss. On the right of the slider, there's the remaining time formatted as -mm:ss.
Styled range input indicating time (live demo)
Screenshot showing three styled sliders withe second one having a tooltip above the thumb indicating the decimal value.
Styled range inputs, one of which has a decimal output (live demo)

Use Case: UI Feedback and Parity

Combining sign and absolute value can detect movement direction. For a volume slider with icons at both ends, compare each icon's sign (-1 or 1) with the sign of the difference between the slider's current and previous values:

[role='group'] {
  --dir: calc(var(--val) - var(--prv));
  --sgn-dir: clamp(-1, var(--dir), 1);
  --sel: 0; /* is the slider focused or hovered? Yes 1/ No 0 */
  
  &:hover, &:focus-within { --sel: 1; }
}

.ico {
  --abs: max(var(--sgn-dir) - var(--sgn-ico), var(--sgn-ico) - var(--sgn-dir));
  --hlg: calc(var(--sel)*(1 - min(1, var(--abs)))); /* highlight current icon? Yes 1/ No 0 */
  opacity: calc(1 - .85*(1 - var(--hlg)));
}

When they match, the absolute value of their difference is 0, and that icon gets highlighted.

Parity checks on grids also become possible. Using the modulo of the sum of column and row distances from the middle gives a checkerboard pattern for background-color:

Screenshot showing a 16x16 grid where each tile is either lime or purple.
Background depending on parity of sum of horizontal and vertical distances to the middle (live demo)

Extending this with floor and mod-2 of the sum produces a more complex tiling:

Screenshot showing a 16x16 grid where each tile is either lime or purple.
A more interesting variation of the previous demo (live demo)

The same parity can drive the direction of a conic-gradient() mask, by flipping elements horizontally when the sum is even:

Grid wave: triangular rainbow worms (live demo).

These techniques also underpin shading in pure CSS 3D shapes — covering both convex and concave geometry without JavaScript — though that topic is substantial enough to warrant its own deep dive.