DEV Community

Snappy Tools
Snappy Tools

Posted on • Originally published at snappytools.app

Build a Tip Calculator in Vanilla JavaScript: a beginner DOM project

A tip calculator is a great first DOM project. It requires user input, real-time calculation, and clean output — but no API calls, no data storage, no complexity you don't control. By the end of this post you'll have a working tip calculator that handles bill splitting too.


The math

Before writing any code, get the arithmetic right:

tip amount   = bill × (tip% / 100)
total        = bill + tip amount
per person   = total / number of people
Enter fullscreen mode Exit fullscreen mode

That's it. The calculator is just this formula wired to input fields.


Step 1 — HTML structure

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Tip Calculator</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>

<div class="card">
  <h1>Tip Calculator</h1>

  <label>Bill amount ($)</label>
  <input type="number" id="bill" min="0" step="0.01" placeholder="0.00">

  <label>Tip percentage</label>
  <div class="tip-buttons">
    <button class="tip-btn" data-tip="15">15%</button>
    <button class="tip-btn" data-tip="18">18%</button>
    <button class="tip-btn" data-tip="20">20%</button>
    <button class="tip-btn" data-tip="25">25%</button>
  </div>
  <input type="number" id="custom-tip" min="0" max="100" placeholder="Custom %">

  <label>Number of people</label>
  <input type="number" id="people" min="1" value="1">

  <div class="results">
    <div class="result-row">
      <span>Tip amount</span>
      <span id="tip-amount">$0.00</span>
    </div>
    <div class="result-row">
      <span>Total</span>
      <span id="total">$0.00</span>
    </div>
    <div class="result-row highlight">
      <span>Per person</span>
      <span id="per-person">$0.00</span>
    </div>
  </div>
</div>

<script src="app.js"></script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Step 2 — JavaScript

The key insight: recalculate on every input event. Don't make the user press a button.

// app.js

let selectedTip = 18; // default

// Tip percentage buttons
document.querySelectorAll('.tip-btn').forEach(btn => {
  btn.addEventListener('click', () => {
    // Deselect all, select this one
    document.querySelectorAll('.tip-btn').forEach(b => b.classList.remove('active'));
    btn.classList.add('active');

    selectedTip = parseFloat(btn.dataset.tip);

    // Clear custom input when a preset is selected
    document.getElementById('custom-tip').value = '';

    calculate();
  });
});

// Custom tip input
document.getElementById('custom-tip').addEventListener('input', () => {
  // Deselect preset buttons
  document.querySelectorAll('.tip-btn').forEach(b => b.classList.remove('active'));

  const val = parseFloat(document.getElementById('custom-tip').value);
  selectedTip = isNaN(val) ? 0 : val;
  calculate();
});

// Bill and people inputs
document.getElementById('bill').addEventListener('input', calculate);
document.getElementById('people').addEventListener('input', calculate);

function calculate() {
  const bill   = parseFloat(document.getElementById('bill').value)   || 0;
  const people = parseInt(document.getElementById('people').value)   || 1;
  const tip    = selectedTip;

  const tipAmount = bill * (tip / 100);
  const total     = bill + tipAmount;
  const perPerson = total / people;

  // Format as currency
  document.getElementById('tip-amount').textContent = '$' + tipAmount.toFixed(2);
  document.getElementById('total').textContent      = '$' + total.toFixed(2);
  document.getElementById('per-person').textContent = '$' + perPerson.toFixed(2);
}

// Run once on load to set initial state
calculate();
Enter fullscreen mode Exit fullscreen mode

Step 3 — Minimal CSS

/* style.css */
body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 100vh;
  background: #f5f6fa;
  margin: 0;
}

.card {
  background: white;
  border-radius: 16px;
  padding: 32px;
  width: 360px;
  box-shadow: 0 4px 20px rgba(0,0,0,0.08);
}

label {
  display: block;
  font-size: 0.875rem;
  font-weight: 600;
  color: #555;
  margin: 16px 0 6px;
}

input[type="number"] {
  width: 100%;
  padding: 10px 14px;
  border: 1px solid #ddd;
  border-radius: 8px;
  font-size: 1rem;
  box-sizing: border-box;
}

.tip-buttons {
  display: flex;
  gap: 8px;
  margin-bottom: 8px;
}

.tip-btn {
  flex: 1;
  padding: 8px;
  border: 2px solid #ddd;
  border-radius: 8px;
  background: white;
  font-weight: 600;
  cursor: pointer;
}

.tip-btn.active {
  border-color: #2f855a;
  background: #f0fff4;
  color: #2f855a;
}

.results {
  margin-top: 24px;
  border-top: 1px solid #eee;
  padding-top: 16px;
}

.result-row {
  display: flex;
  justify-content: space-between;
  padding: 8px 0;
}

.result-row.highlight {
  font-size: 1.25rem;
  font-weight: 700;
  color: #2f855a;
}
Enter fullscreen mode Exit fullscreen mode

Edge cases to handle

The simple version above works, but a production-ready calculator handles a few more situations:

Negative bills and zero people

function calculate() {
  const bill   = Math.max(0, parseFloat(document.getElementById('bill').value) || 0);
  const people = Math.max(1, parseInt(document.getElementById('people').value) || 1);
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Math.max(0, ...) prevents negative bills. Math.max(1, ...) prevents dividing by zero.

Rounding errors

0.1 + 0.2 === 0.30000000000000004 in JavaScript. Always display with .toFixed(2) and only do the rounding at display time, not during calculation.

Non-numeric input

parseFloat('abc') returns NaN. The || 0 fallback in const bill = parseFloat(...) || 0 handles this cleanly.


What makes a tip calculator feel polished

A few details that separate a demo from something people actually use:

  1. Default to 18–20% so the output is meaningful immediately
  2. Update on every keystroke — no "calculate" button
  3. Highlight the per-person amount — that's the number people care about at the table
  4. Handle 1 person cleanly — don't show "per person" math if splitting with 1 person

Try a working version

If you want to see a complete implementation — including tip pooling, dark mode, and mobile layout — SnappyTools Tip Calculator is a free, no-signup version you can use as a reference.


What to build next

Once this is working, natural extensions:

  • Rounding mode: round each person's share up to the nearest dollar
  • Tip pool distribution: split tips among multiple staff at different percentage weights
  • Currency formatting: Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD'}).format(n)
  • Persistent state: localStorage to remember the last-used tip percentage

The Intl.NumberFormat approach is cleaner than manual string building once you need real currency formatting.

Top comments (0)