CSS
    layout

    How to use CSS math functions round, mod, and rem for layout calculations

    CSS now supports advanced mathematical functions like round(), mod(), and rem() natively. This article explains how to use these functions to calculate dynamic grid tracks, snap typography to a baseline grid, and avoid complex JavaScript resize listeners.

    Editor: Paul RadfordJul 2, 20267 min read
    How to use CSS math functions round, mod, and rem for layout calculations

    How to use CSS math functions round, mod, and rem for layout calculations

    CSS math functions round(), mod(), and rem() let the browser snap values to a grid, wrap numbers into a cycle, and calculate remainders directly inside a stylesheet property value, recalculated automatically on every layout pass. That means you can align fluid widths and typography to a fixed baseline unit without a resize listener or a JavaScript recalculation loop.

    For years, "round this fluid value to the nearest multiple of 8px" meant a ResizeObserver, a debounce function, and a chunk of JS writing a custom property back onto the document. That category of problem is mostly solved now. The CSS Values and Units spec defines a full set of math functions, and three of them, round(), mod(), and rem(), do work that used to require script, running natively during layout at no JS cost.

    This article covers where those three functions earn their keep, where they don't, and how to fall back safely when a browser doesn't support them yet.

    What do round(), mod(), and rem() actually do?

    All three take two length or number arguments and return a value with a unit. The difference is in rounding direction and how each handles negative numbers.

    round(<strategy>, A, B) rounds A to the nearest multiple of B. The strategy argument is nearest (default), up, down, or to-zero. So round(nearest, 17px, 8px) returns 16px, while round(up, 17px, 8px) returns 24px.

    mod(A, B) returns the remainder of A / B, with the sign of the result always matching the sign of B. This is a true modulo, useful for cycling through a fixed set of values: column widths, hues, delay offsets.

    rem(A, B) also returns a remainder, but the sign matches A instead of B. This is the "programmer's remainder" from JavaScript's % operator or C's fmod. For positive operands, mod() and rem() return the same value. They only diverge when one operand is negative, which matters once you're working with scroll offsets, translateX values, or rotation angles that can legitimately go negative.

    Full behavior and edge cases, including the sign truth tables, are documented on MDN's round() and mod() reference pages, and formally in the CSS Values and Units spec.

    How do you snap a fluid width to a fixed multiple with round()?

    This is the pattern I reach for most. Say you're building a card grid where the card width scales with the viewport, but you want every computed width to land on an 8px unit so borders and internal padding don't produce subpixel blur.

    css
    1.card {
    2 /* Step 1: a fluid, unrounded width using clamp() */
    3 --fluid-width: clamp(240px, 22vw + 1rem, 420px);
    4
    5 /* Step 2: snap that value to the nearest 8px increment */
    6 width: round(nearest, var(--fluid-width), 8px);
    7
    8 /* Alternative: round up so cards never drop below a grid line */
    9 /* width: round(up, var(--fluid-width), 8px); */
    10}

    The browser recalculates round() on every reflow, exactly like clamp() or calc(). Resize the window and the width steps between 8px increments with no flash and no ResizeObserver callback queue to manage.

    You can extend this to compute how many fixed-width tracks fit inside a container, then let the grid distribute leftover space:

    css
    1.grid {
    2 --track-size: 220px;
    3 --gap: 1rem;
    4
    5 /* Round down so the count never overflows the container */
    6 --track-count: round(
    7 down,
    8 calc(100% / (var(--track-size) + var(--gap))),
    9 1
    10 );
    11
    12 display: grid;
    13 grid-template-columns: repeat(var(--track-count), 1fr);
    14 gap: var(--gap);
    15}

    Rounding down here is deliberate, not a style preference. If you round to nearest, you risk generating one column too many, which forces the last track into overflow or squeezes every track narrower than --track-size. round(down, ..., 1) guarantees an integer count that always fits, at the cost of leaving a little unused space on the trailing edge. That trade-off is almost always the right one for a grid; it's the wrong one if the whole point of the layout is to fill every pixel of width, in which case you're better off with plain auto-fit and minmax().

    Can mod() replace nth-child for cyclic styling?

    Sometimes, and it's worth being precise about when. mod() is useful when the cycle length itself is a variable, driven by a design token or a value computed once outside the loop, not hardcoded into a selector.

    css
    1.tile {
    2 /* --i is set inline per element via a templating loop or CMS export */
    3 --cycle: mod(var(--i), 3);
    4 /* cycle repeats 0, 1, 2, 0, 1, 2 ... regardless of how large --i gets */
    5
    6 aspect-ratio: calc(1 + var(--cycle) * 0.2);
    7}
    html
    1<!-- Index written inline; mod() handles the wrapping in CSS -->
    2<div class="tile" style="--i: 0;"></div>
    3<div class="tile" style="--i: 1;"></div>
    4<div class="tile" style="--i: 2;"></div>
    5<div class="tile" style="--i: 3;"></div>

    This avoids re-running a JS loop that reassigns classes every time the DOM changes. But don't reach for it as a default. If your cycle really is fixed at every-third-item, :nth-child(3n+1) is cheaper for the browser to evaluate, needs no custom property, and doesn't require you to write an index into every element's markup. Use mod() when the cycle length changes based on a breakpoint, a container query, or a value your backend controls, not as a blanket replacement for structural selectors.

    How do you snap typography to a baseline grid?

    Baseline grids are the other place these functions pay for themselves. If your type system specifies a 4px vertical rhythm, you want every heading's line-height and margin to land on a multiple of that unit, even as font size scales fluidly with clamp().

    css
    1: root {
    2 --baseline: 4px;
    3}
    4
    5h2 {
    6 font-size: clamp(1.25rem, 1rem + 1.5vw, 2rem);
    7
    8 /* Snap line-height to the nearest multiple of the baseline unit */
    9 line-height: round(nearest, 1.4em, var(--baseline));
    10
    11 /* Snap bottom margin the same way to keep spacing consistent */
    12 margin-block-end: round(up, 1.2em, var(--baseline));
    13}

    This keeps text blocks aligned to vertical rhythm across every breakpoint on a continuous fluid curve, instead of the old approach of precomputing line-heights at three or four fixed breakpoints in Sass and hoping nothing in between looked wrong. One real gotcha: mixing em values with a px-based baseline works because the browser resolves em to an absolute length before running the round math, but the resolved value depends on the element's computed font size at that point in the cascade. Check computed values in DevTools the first time you do this on a component with inherited font-size overrides; it's the most common source of "why isn't this rounding to what I expect."

    What's the real browser support story, and do you need a fallback?

    Support is solid but not universal. round(), mod(), and rem() shipped in Chrome and Edge 125, Firefox 118, and Safari 18. That's essentially all evergreen desktop and mobile traffic on current versions, but you'll still hit gaps on any Safari release before 18 (a meaningful chunk of iOS traffic on devices that haven't updated) and on older Chromium-based WebViews embedded in native apps. Check current adoption on caniuse before shipping if your analytics show real legacy traffic.

    The safe pattern is a feature query with a static fallback, not a JS shim:

    css
    1.card {
    2 /* Fallback: a fixed width that's already a multiple of 8px */
    3 width: 240px;
    4}
    5
    6@supports (width: round(nearest, 1px, 1px)) {
    7 .card {
    8 width: round(nearest, clamp(240px, 22vw + 1rem, 420px), 8px);
    9 }
    10}

    Unsupported browsers get a static, still-on-grid width instead of the fluid one. You lose fluidity, not design integrity, and that's a far better trade than shipping a polyfill that recalculates layout on scroll and resize on low-power devices.

    One more thing to flag: these functions resolve inside property values, not inside media or container query conditions. If you need rounding logic tied to a breakpoint, put the round() call inside the declaration nested under the relevant @container or @media block rather than trying to embed it in the query test itself. The MDN CSS Values and Units overview covers where each math function is and isn't valid.

    Key Takeaways

    • round(), mod(), and rem() shipped in Chrome/Edge 125, Firefox 118, and Safari 18, but pre-18 Safari and older embedded WebViews still need a fallback.
    • round(down, value, 1) is the safest strategy for computing grid track counts because it guarantees the result never overflows the container.
    • mod() and rem() match for positive operands and diverge only when either operand is negative, which matters for scroll-driven or animated offsets.
    • Wrap production use in @supports (width: round(nearest, 1px, 1px)) and fall back to a static, pre-rounded value rather than a JS polyfill.
    • Use mod() for cyclic styling only when the cycle length is a variable; a fixed cycle is cheaper and clearer with :nth-child().

    Advertisement

    728 × 90 — Leaderboard — Google AdSense

    Related Articles