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
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>
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();
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;
}
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);
// ...
}
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:
- Default to 18–20% so the output is meaningful immediately
- Update on every keystroke — no "calculate" button
- Highlight the per-person amount — that's the number people care about at the table
- 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:
localStorageto 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)