15 min read
JavaScript Math Functions You’ll Actually Use (and the Traps to Avoid)
Hassib ModdasserSoftware engineerMost of the math in a real interface is simple. You round a price, work out how many pages a list needs, keep a slider between its limits, or pick a random tip for a loading screen. It’s also where some of the most embarrassing bugs come from: a cart total of $0.30000000000000004, a “Page 1 of 2.5”, a “Highest bid: -Infinity”, or a discount badge promising you’ll save 25.31645569620253%.
JavaScript’s built-in Math object has a function for each of those jobs. In this article, I’ll go through the ones I use most, the real interface problems they solve, and the traps that come with them. At the end, we’ll use several of them together to build pagination in React.
Math in 30 seconds
Math isn’t something you create. It’s a built-in object that holds a set of functions and constants, and you call them directly: Math.round(4.6), Math.PI. They all work with regular numbers. Pass a BigInt and you’ll get a TypeError.
One more thing explains half the bugs in this article. JavaScript stores numbers in binary floating point, and most decimal fractions can’t be stored exactly in binary. That’s why 0.1 + 0.2 gives you 0.30000000000000004. It isn’t a JavaScript bug, and most programming languages do the same thing. You can’t avoid it, but you can handle it, and you’ll see how below.
Rounding: round, floor, ceil, and trunc
These four all turn a decimal into a whole number, but each one goes in a different direction:
Math.round()goes to the nearest whole number.Math.floor()always goes down.Math.ceil()always goes up.Math.trunc()just cuts off the decimals.
For positive numbers, floor and trunc give the same result. The difference shows up below zero, where “down” means further away from zero:
| Number | round | floor | ceil | trunc |
|---|---|---|---|---|
| 2.4 | 2 | 2 | 3 | 2 |
| 2.5 | 3 | 2 | 3 | 2 |
| 2.6 | 3 | 2 | 3 | 2 |
| -2.4 | -2 | -3 | -2 | -2 |
| -2.5 | -2 | -3 | -2 | -2 |
| -2.6 | -3 | -3 | -2 | -2 |
Look at -2.5 in the round column. Math.round() rounds halves up, toward positive infinity, so -2.5 becomes -2, not -3. If you display negative values like temperatures or balance changes, that’s worth knowing before someone files a bug about it.
Each of the four has a job it’s best at.
ceil: how many do I need?
Whenever the question is “how many do I need”, round up. 47 products at 12 per page don’t fit on 3.9 pages. They need 4:
const totalPages = Math.ceil(47 / 12) // 4Forget the ceil, and you get the “Page 1 of 2.5” from the start of this article: 30 items divided by 12. The same goes for rows in a grid, boxes in a shipment, or the reading time at the top of an article like this one:
const readingTime = Math.ceil(wordCount / 225) // 1,950 words → 9 minfloor: how many fit?
Round down when the question is “how many whole things fit”. For example, how many 260px cards fit in a 1200px row:
const columns = Math.floor(1200 / 260) // 4Math.round() would say 5, since 1200 / 260 is about 4.6, and the fifth card wouldn’t fit.
floor is also the one for time. Here’s how a video player turns seconds into the familiar 3:07:
function formatDuration(totalSeconds) {
const minutes = Math.floor(totalSeconds / 60)
const seconds = Math.floor(totalSeconds % 60)
return `${minutes}:${String(seconds).padStart(2, '0')}`
}
formatDuration(187) // '3:07'
formatDuration(59.8) // '0:59'Use Math.round() for the seconds, and 59.8 seconds shows up as 0:60. The padStart() adds the leading zero, and if you want more tricks like that, my guide to JavaScript string methods has plenty.
round: numbers people read
Math.round() is the one for numbers people read, like the label on a progress bar:
const percent = Math.round((37 / 120) * 100) // 31, not 30.833333333333336Or the discount badge from the start: Math.round((1 - 59 / 79) * 100) gives you a clean 25.
A nice trick: to round to the nearest half instead of the nearest whole number, multiply by 2 first and divide after. That’s how star ratings get their half stars:
const stars = Math.round(4.3 * 2) / 2 // 4.5The same idea works for any step. Math.round(value / 5) * 5 rounds to the nearest 5, which is handy for slider steps and chart labels.
Rounding to one decimal place works the same way, with 10 instead of 2. Here it is in a file size formatter for an upload form:
function formatFileSize(bytes) {
const units = ['B', 'KB', 'MB', 'GB']
let size = bytes
let unit = 0
while (size >= 1024 && unit < units.length - 1) {
size /= 1024
unit++
}
return `${Math.round(size * 10) / 10} ${units[unit]}`
}
formatFileSize(800) // '800 B'
formatFileSize(1536) // '1.5 KB'
formatFileSize(2_516_582) // '2.4 MB'trunc: just drop the decimals
Math.trunc() cuts off the decimals no matter the sign. It’s useful when a value can be negative and you want whole units in both directions, like the days until a deadline, or the days since it passed:
Math.trunc(1.5) // 1
Math.trunc(-1.5) // -1
Math.floor(-1.5) // -2With floor, a deadline that passed a day and a half ago shows as “2 days ago”, while one coming up in a day and a half shows as “in 1 day”. trunc treats both sides the same.
Rounding to decimal places, and money
Rounding to two decimal places is where things get tricky. The classic pattern multiplies, rounds, and divides:
Math.round(2.4567 * 100) / 100 // 2.46It works most of the time. Then floating point strikes again:
const price = 1.005
Math.round(price * 100) / 100 // 1, not 1.01
price.toFixed(2) // '1.00'1.005 can’t be stored exactly. It’s really 1.00499999999999989..., so both of these correctly round it down. toFixed() has a second catch: it returns a string. That’s fine for display, but (2.5).toFixed(2) + 1 gives you '2.501'.
For money, the reliable approach is the one payment providers like Stripe use: store amounts as whole numbers in the smallest unit, like cents, and only convert for display.
There’s a trap even here. Converting 19.99 to cents with Math.floor() loses a cent, because 19.99 * 100 is 1998.9999999999998. Use Math.round() for that conversion:
Math.floor(19.99 * 100) // 1998, one cent short
Math.round(19.99 * 100) // 1999Once everything is in cents, adding up is exact. Then let Intl.NumberFormat handle the display, including the currency symbol, the thousands separators, and the right decimals for the user’s language:
const cart = [1999, 501, 10] // prices in cents
const total = cart.reduce((sum, cents) => sum + cents, 0) // 2510
const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
usd.format(total / 100) // '$25.10'Change the locale to 'de-DE' and the currency to 'EUR', and 1234.5 comes out as 1.234,50 €, without you having to know that Germans use a comma for decimals.
Good to know
Intl.NumberFormat also does percentages (style: 'percent') and short numbers like “1.2K” followers (notation: 'compact'). Before you write a formatting function by hand, check whether it already does what you need.
Smallest and largest: Math.min and Math.max
Math.max() returns the largest of the numbers you give it, and Math.min() the smallest. For an array, spread it into the call:
Math.max(2, 5, 15, 3) // 15
const prices = [1000, 2500, 400, 14000, 800]
Math.max(...prices) // 14000
Math.min(...prices) // 400A few traps here. First, an empty array doesn’t throw an error. It quietly gives you -Infinity for the max and Infinity for the min, and that’s how “Highest bid: -Infinity” ends up on a live page. Handle the empty case yourself:
const highestBid = bids.length > 0 ? Math.max(...bids) : nullSecond, spreading turns every item into a separate argument, and engines limit how many arguments one call can take. With very large arrays, think tens of thousands of items or more depending on the browser, you can get a RangeError. For big datasets, use reduce():
const highest = prices.reduce((max, price) => Math.max(max, price), -Infinity)And if any value can’t be turned into a number, the result is NaN. Data from forms and APIs often arrives as strings, so convert it before you compare.
Clamping
The most useful thing about min and max is using them together. Clamping keeps a value between two limits:
const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
clamp(15, 1, 10) // 10
clamp(-3, 1, 10) // 1
clamp(4, 1, 10) // 4Once you have it, you’ll see uses everywhere:
- A quantity picker that can’t go below 1 or above what’s in stock:
clamp(quantity, 1, stock). - Zoom levels and volume sliders that have to stay inside their range.
- Progress values that must stay between 0 and 1, even when the browser reports a scroll position slightly outside the page.
My favorite is a tooltip that follows the cursor but never slides off the edge of the screen. The 8 keeps a small gap from each edge:
const left = clamp(pointerX - tooltipWidth / 2, 8, window.innerWidth - tooltipWidth - 8)CSS has its own clamp() for things like fluid font sizes. It takes the same three ideas, a minimum, a preferred value, and a maximum, just in a different order.
Watch out
If any argument is NaN, both Math.min() and Math.max() return NaN, so a clamp won’t protect you from bad input. When a value comes from the URL or a form, convert it with a fallback first, like Number(value) || 1.
Distance and direction: abs, sign, and hypot
Math.abs() gives you a number’s distance from zero, so the result is never negative. That makes it the right tool for “how far apart are these?” when you don’t care which one is bigger:
Math.abs(4 - 7) // 3
Math.abs(7 - 4) // 3Math.sign() gives you the opposite: only the direction. It returns -1 for negative numbers, 1 for positive ones, and 0 for zero. Together, they make swipe gestures easy. Here’s a carousel that ignores tiny movements and moves one slide in the direction of the swipe:
function handleSwipe(startX, endX) {
const distance = endX - startX
if (Math.abs(distance) < 50) return // too short, treat it as a tap
setSlide((slide) => clamp(slide - Math.sign(distance), 0, slides.length - 1))
}Swiping left gives a negative distance, so Math.sign() returns -1 and the carousel moves to the next slide. And clamp() from the previous section keeps it from going past the first or last one.
Math.sign() is also handy for showing whether a number went up or down, like the arrow next to a stock price or a dashboard metric:
const arrows = { '-1': '▼', 0: '•', 1: '▲' }
arrows[Math.sign(12.5)] // '▲'
arrows[Math.sign(-3.2)] // '▼'Math.hypot() returns the straight-line distance between two points, given how far apart they are horizontally and vertically. It’s the Pythagorean theorem in one call, so you don’t have to write Math.sqrt(dx ** 2 + dy ** 2) yourself:
Math.hypot(3, 4) // 5You need it to check how far the pointer moved before treating a press as a drag, or to know how close the cursor is to something. The dotted “hassib.co” in the footer of this site uses it to measure the distance from your cursor to every single dot, and the dots close enough to it get darker. If you’re on a computer, move your mouse over it when you’re done reading.
Powers and roots: pow, sqrt, and cbrt
Math.pow(base, exponent) raises a number to a power. Modern JavaScript has the ** operator for the same thing, and it’s what you’ll mostly see now:
Math.pow(2, 10) // 1024
2 ** 10 // 1024A real-world use is retrying failed requests with exponential backoff: wait 1 second, then 2, then 4, so a struggling server gets some room to recover. Math.min() caps the wait so it doesn’t grow forever:
const delay = Math.min(1000 * 2 ** attempt, 10_000)
// attempts 0 to 4: 1000, 2000, 4000, 8000, 10000Or the savings calculator on a bank’s website, which is compound interest. $1,000 at 5% a year for 10 years:
const amount = 1000 * (1 + 0.05) ** 10 // 1628.8946267774422Formatted with Intl.NumberFormat, that’s a friendly $1,628.89.
Math.sqrt() and Math.cbrt() return square and cube roots: Math.sqrt(9) is 3, Math.cbrt(27) is 3, and Math.sqrt(-9) is NaN. Honestly, outside of distance calculations, where Math.hypot() is nicer anyway, they rarely come up in interface code.
Random numbers
Math.random() returns a decimal between 0 (included) and 1 (not included). That’s rarely what you want on its own, so you scale it. Here’s a random whole number between two values, both included:
function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min
}
randomInt(1, 6) // a dice roll: 1, 2, 3, 4, 5, or 6Use Math.floor() here, not Math.round(). With Math.round(Math.random() * 5) + 1, the 1 and the 6 come up only half as often as the other numbers, because they each get half a rounding range.
You’ll also see parseInt(Math.random() * 100) in older tutorials. I used it myself in the first version of this article. Avoid it. parseInt() is made for parsing strings, so it turns the number into text first, and very small numbers turn into scientific notation:
parseInt(0.0000005) // 5, because the number becomes the string '5e-7'
Math.floor(0.0000005) // 0Picking a random item from an array, like a tip on a loading screen or a featured testimonial, works the same way:
const tips = ['Press / to search', 'Drag cards to reorder', 'Double-click to rename']
const tip = tips[Math.floor(Math.random() * tips.length)]To shuffle a list, like the answers in a quiz, use the Fisher-Yates shuffle. It walks backwards through the array and swaps each item with a random one at or before it:
function shuffle(items) {
const result = [...items]
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
const temp = result[i]
result[i] = result[j]
result[j] = temp
}
return result
}You might see list.sort(() => Math.random() - 0.5) as a one-line shuffle. It’s tempting, but the results aren’t evenly random. Shuffle ['a', 'b', 'c'] that way a few hundred thousand times in Node and count the results, and some orders come up about six times as often as others.
Watch out
Math.random() isn’t built for security. Never use it for passwords, tokens, or anything someone could gain from guessing. For unique IDs, use crypto.randomUUID(), and for secure random values, crypto.getRandomValues(). Both are built into modern browsers and Node, though browsers only offer randomUUID() on HTTPS pages and localhost.
One more trap if you use React with server rendering, like Next.js. If a component calls Math.random() while it renders, the server picks one number and the browser picks another, and React warns about a hydration mismatch. For IDs that connect a label to an input, use React’s useId(). For random content, like that loading tip, pick it in an event handler or after the component mounts. And never use Math.random() for a key: a new key on every render makes React throw away and rebuild that element each time.
Putting it together: pagination in React
Let’s use several of these at once. Pagination looks simple until real data shows up: an empty list, a page number someone edited in the URL, or a filter that leaves fewer pages than the one you’re on. ceil, min, and max handle all three:
function paginate(items, requestedPage, perPage = 12) {
const totalPages = Math.max(1, Math.ceil(items.length / perPage))
const page = Math.min(Math.max(requestedPage, 1), totalPages)
const start = (page - 1) * perPage
return {
page,
totalPages,
items: items.slice(start, start + perPage),
from: items.length === 0 ? 0 : start + 1,
to: Math.min(start + perPage, items.length),
}
}And a product grid that uses it:
function ProductGrid({ products }) {
const [requestedPage, setRequestedPage] = useState(1)
const { page, totalPages, items, from, to } = paginate(products, requestedPage)
return (
<>
<p>
Showing {from} to {to} of {products.length}
</p>
<ul>
{items.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
<nav aria-label="Pagination">
<button disabled={page === 1} onClick={() => setRequestedPage(page - 1)}>
Previous
</button>
<span>
Page {page} of {totalPages}
</span>
<button disabled={page === totalPages} onClick={() => setRequestedPage(page + 1)}>
Next
</button>
</nav>
</>
)
}Here’s what each piece of math is doing:
Math.ceil()makes the last, partly filled page count. 47 products make 4 pages, and the last one shows products 37 to 47.Math.max(1, ...)means there’s always at least one page. An empty list shows “Page 1 of 1” instead of “Page 0 of 0”.- The page is clamped, not trusted. If a filter shrinks the list while you’re on page 4, you land on the last page that exists instead of an empty one. The same goes for a page number from the URL, as long as you convert it first with
Number(value) || 1. - Only the requested page lives in state. The real page, the totals, and the visible products are calculated during render, so they can’t get out of sync.
Quick reference
Here are the patterns from this article in one place:
| What you want | Code |
|---|---|
| Round to the nearest whole number | Math.round(x) |
| Round down or up | Math.floor(x), Math.ceil(x) |
| Drop the decimals | Math.trunc(x) |
| Round to 2 decimals for display | Math.round(x * 100) / 100 |
| Count pages | Math.ceil(total / perPage) |
| Convert a price to cents | Math.round(price * 100) |
| Format money | new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(x) |
| Largest or smallest in an array | Math.max(...list), Math.min(...list) |
| Keep a value between limits | Math.min(Math.max(x, min), max) |
| Distance from zero, direction | Math.abs(x), Math.sign(x) |
| Distance between two points | Math.hypot(x2 - x1, y2 - y1) |
| Powers | 2 ** 10 |
| Random whole number | Math.floor(Math.random() * (max - min + 1)) + min |
| Secure random ID | crypto.randomUUID() |
Wrapping up
Most interface math comes down to a few habits. Round only when you display a number, and pick the direction on purpose: ceil for “how many do I need”, floor for “how many fit”, and round for what people read. Keep money in cents and let Intl.NumberFormat format it. Clamp anything that comes from a user. And use Math.random() for fun things like tips and shuffles, but crypto for anything that needs to be secure.
If you have questions, or you’re building something and want a second pair of eyes, feel free to reach out on LinkedIn or through my contact page. I’m always happy to help.