DEV Community

ggwork
ggwork

Posted on

Building a BMI Calculator Without a Single API Call: Lessons in Pure Frontend Architecture

I was staring at my health app's analytics dashboard when it hit me — I needed a quick BMI calculation for a side project, and the thought of adding another third-party dependency to my already bloated bundle made me physically wince. "How hard can it be?" I thought, reaching for my keyboard. "It's just a formula and a couple of inputs."

Spoiler: it was never just a formula.

The Problem Nobody Talks About

Most BMI calculators you'll find online are either:

  1. A wall of JavaScript that could've been a simple form
  2. A React app where the build process alone takes longer than the calculation
  3. A server-rendered mess that makes you wonder why a basic math operation needs a database

I wanted something different: a single HTML file that works offline, respects user privacy, and doesn't require a full framework to calculate what a 10-year-old could do with a calculator.

The real challenge wasn't the math — it was the UX decisions that came after.

The Unit Conversion Trap

Here's where things get interesting. The UK uses stone, the US uses pounds, and most of the world uses kilograms. Building a tool that handles metric and imperial without losing your sanity requires some actual thought.

The first version I wrote treated height as a single number. That worked great for metric users, but imperial users think in feet and inches — they don't want to type "5.5" and hope it means 5'6". They want separate inputs.

function imperialToMetric(feet, inches, pounds) {
  const totalInches = (feet * 12) + inches;
  const heightMeters = totalInches * 0.0254;  // 1 inch = 0.0254m
  const weightKg = pounds * 0.453592;          // 1 lb = 0.453592kg
  return { heightMeters, weightKg };
}
Enter fullscreen mode Exit fullscreen mode

The key insight? Always convert to metric internally, then format for display. This prevents the classic "I converted the display but not the calculation" bug that plagues so many unit converters.

The Visualization Headache

Displaying a BMI value is easy. Making it meaningful? That's where the real engineering happens.

I initially built a simple progress bar. Then I realized that's misleading — BMI categories aren't linear. The difference between "normal" (18.5) and "underweight" (<18.5) is much more significant than the difference between 22 and 23, even though they're the same numerical distance.

The solution was a segmented gauge where each segment's width represents the actual range of that category. This way, users can visually grasp that "overweight" is a wider range than "normal" — which is both mathematically accurate and visually honest.

AI-Assisted Development: The Good, The Bad, The Humorous

I decided to use AI for this project, partly out of curiosity and partly because I was tired of writing the same CSS reset for the hundredth time.

What worked well:

  • The AI handled the i18n structure beautifully. I described my requirements in plain English, and it generated a clean translation object with both Chinese and English strings.
  • The initial layout structure was solid — the AI understood the visual hierarchy without me having to specify every pixel.

What failed spectacularly:

  • The first version had a bug where switching between metric and imperial would preserve the value but not the unit. So switching from 170cm to imperial would show "170 ft" instead of converting to feet and inches. Classic AI hallucination — it understood the concept but missed the semantic difference.
  • The gauge marker positioning was off. The AI generated margin-top: -16px as a magic number that worked in its test case but broke in real-world usage.
.gauge-marker {
  /* The AI's version: */
  position: relative;
  margin-top: -16px;  /* Why 16? Nobody knows. */

  /* My fix: */
  position: absolute;
  top: -4px;
  transform: translateX(-50%);
}
Enter fullscreen mode Exit fullscreen mode

The lesson? AI is excellent at generating boilerplate and structure, but it still needs human oversight for edge cases and semantic correctness.

The Privacy Angle

One of the best decisions I made was keeping everything client-side. No API calls, no server-side processing, no analytics tracking.

This isn't just about privacy — it's about performance. The entire tool loads in under 100KB and works instantly, even on a 2G connection. There's no loading spinner, no skeleton screen, no "please wait while we process your request."

The trade-off? I can't track usage patterns or A/B test different layouts. But for a utility tool, I'd argue that's a feature, not a bug.

Input Validation: The Boring but Crucial Part

Here's something nobody talks about when showing off their fancy calculators: what happens when someone enters 0 for height or -50 for weight?

The formula weight / (height * height) will happily calculate 0 / 0 = NaN or return Infinity if you're not careful. And if you don't catch it, your beautiful gauge suddenly points to nowhere.

function validateInputs(height, weight) {
  if (height <= 0 || weight <= 0) {
    return { valid: false, error: 'Please enter positive values' };
  }
  if (height > 300 || weight > 500) {
    return { valid: false, error: 'Values seem unreasonable' };
  }
  return { valid: true };
}
Enter fullscreen mode Exit fullscreen mode

The tricky part? Deciding what counts as "unreasonable." 300cm is tall but not impossible. 500kg is extreme but exists. I eventually settled on generous limits that catch obvious typos without being judgmental.

Design Decisions That Mattered

Dark mode support wasn't just a nice-to-have — it was a requirement. I used CSS custom properties and prefers-color-scheme to handle this without JavaScript.

Responsive design meant more than just scaling down. On mobile, the two-column input grid needs to become single-column, and the gauge labels need to be readable at 320px width.

Font choices matter more than you'd think. I used a monospace font for numbers because proportional fonts cause digits to shift as they change, creating a jittery effect that's surprisingly distracting.

The i18n Trap

Internationalization seems straightforward until you realize that "5' 10\"" in English becomes something completely different in other cultures. My initial approach of just translating the labels missed the point — the format of the numbers needed to change too.

The solution was to separate the translation logic from the formatting logic:

const i18n = {
  zh: {
    height: '身高',
    weight: '体重',
    normalRange: '正常范围'
  },
  en: {
    height: 'Height',
    weight: 'Weight',
    normalRange: 'Normal Range'
  }
};

// Formatting stays separate
function formatHeight(cm, lang) {
  if (lang === 'en') return `${Math.floor(cm/30.48)}' ${((cm%30.48)/2.54).toFixed(0)}"`;
  return `${cm}cm`;
}
Enter fullscreen mode Exit fullscreen mode

This separation made testing much easier — I could verify the math independently of the language.

What I'd Do Differently

Looking back, there are a few things I'd change:

  1. Better error handling for extreme values — I focused on the common cases but could've handled edge cases more gracefully.
  2. More granular BMI categories — The WHO standard has 7 categories, but I only implemented 4. For a clinical tool, you'd want the full range including "severe obesity."
  3. History tracking — Storing previous calculations in localStorage would be useful for people tracking their progress.

The Real Takeaway

Building this tool taught me that even "simple" utilities have layers of complexity. The BMI formula is three lines of code, but making it usable across cultures, devices, and user preferences requires careful engineering.

The most valuable lesson? Sometimes the best architecture is the simplest one. No build step, no dependencies, no server — just a well-crafted HTML file that does one thing and does it well.

During this process, I built a small browser-based tool to make this workflow easier. You can find it at Craftvo's BMI Calculator.

If you're building similar tools, my advice is this: start with the math, obsess over the UX, and never trust the AI to understand unit conversions without testing them yourself. Trust me on that last one.

Top comments (0)