A few months ago I set out to build a suite of financial calculators for the Malaysian market. Stamp duty, EPF contributions, zakat, rental yield, unit trust fees — the kind of tools that exist on clunky government portals or behind paywalls on financial sites.
My constraint: no backend, no database, no build step. Just files I could push to GitHub Pages and have live in minutes.
Here's what I learned.
Why Vanilla JS?
The first question developers ask is: why not React/Vue/Svelte?
For calculator tools, a framework is pure overhead. Here's the math:
- A React app with a single calculator ships ~40-100KB of JS just for the runtime
- A vanilla JS calculator ships maybe 3-8KB total
- GitHub Pages serves static files globally via CDN with zero config
For users on Malaysian mobile networks (where 4G coverage is patchy outside KL), this matters. PageSpeed scores matter for SEO. And frankly, a compound interest formula doesn't need reactive state management.
The other reason: longevity. Framework versions go stale. A HTML file with inline JS will still work in 10 years without npm audit warnings.
The Calculator Architecture
Each calculator follows the same pattern:
<!-- 1. Input form with labeled fields -->
<form id="calc-form">
<div class="field">
<label for="property-value">Property Price (RM)</label>
<input type="number" id="property-value" placeholder="500000">
</div>
<!-- more fields... -->
<button type="submit">Calculate</button>
</form>
<!-- 2. Results section, hidden until computed -->
<div id="results" class="hidden">
<div class="result-card">
<span class="label">Stamp Duty</span>
<span class="value" id="stamp-duty-result">—</span>
</div>
</div>
The JS is an event listener that reads inputs, runs the formula, and updates the DOM:
document.getElementById('calc-form').addEventListener('submit', function(e) {
e.preventDefault();
const price = parseFloat(document.getElementById('property-value').value) || 0;
const duty = calcStampDuty(price);
document.getElementById('stamp-duty-result').textContent = formatRM(duty);
document.getElementById('results').classList.remove('hidden');
});
No state. No virtual DOM. No hooks. Just: read → compute → write.
The Stamp Duty Calculator: A Case Study
Malaysian stamp duty on property transfers uses a tiered rate structure under the Stamp Act 1949. The tiers are:
| Property Value | Rate |
|---|---|
| First RM100,000 | 1% |
| RM100,001 – RM500,000 | 2% |
| RM500,001 – RM1,000,000 | 3% |
| Above RM1,000,000 | 4% |
The tricky part is correctly computing the marginal (not flat) rate. Lots of online calculators get this wrong by applying one rate to the whole amount.
function calcStampDuty(price) {
let duty = 0;
if (price <= 100000) {
duty = price * 0.01;
} else if (price <= 500000) {
duty = 100000 * 0.01 + (price - 100000) * 0.02;
} else if (price <= 1000000) {
duty = 100000 * 0.01 + 400000 * 0.02 + (price - 500000) * 0.03;
} else {
duty = 100000 * 0.01 + 400000 * 0.02 + 500000 * 0.03 + (price - 1000000) * 0.04;
}
return duty;
}
For a RM600,000 property, this correctly returns RM13,000 (not RM18,000 which a flat-rate calculator would give).
You can try it at: Stamp Duty Calculator
EPF: Statutory Rates With Edge Cases
The Employees Provident Fund calculator is more interesting because the contribution rates change based on age and whether the employee is a Malaysian citizen.
For employees under 60: employee contributes 11%, employer contributes 13% (for salaries ≤RM5,000) or 12% (above RM5,000).
function calcEPF(salary, age, isCitizen) {
if (!isCitizen) {
return { employee: 0, employer: salary * 0.05 };
}
const employeeRate = age >= 60 ? 0.055 : 0.11;
const employerRate = age >= 60 ? 0.04 : (salary <= 5000 ? 0.13 : 0.12);
return {
employee: salary * employeeRate,
employer: salary * employerRate,
total: salary * (employeeRate + employerRate)
};
}
The key insight: encode the rules as data before you write the formula. I kept a separate EPF_RATES object that documents which Budget year each rate change came from. When rules update, I update the object, not the logic.
SEO: JSON-LD Structured Data
Static sites can rank well if you help Google understand the content. For calculator pages, I use two JSON-LD schemas:
WebApplication — tells Google this is an interactive tool:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Malaysia Stamp Duty Calculator 2025",
"applicationCategory": "FinanceApplication",
"operatingSystem": "Web",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "MYR"
}
}
</script>
FAQPage — captures long-tail queries directly in search results:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [{
"@type": "Question",
"name": "How is stamp duty calculated in Malaysia?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Stamp duty uses tiered rates: 1% on first RM100,000, 2% on RM100,001–RM500,000, 3% on RM500,001–RM1,000,000, and 4% above RM1,000,000."
}
}]
}
</script>
FAQ rich results show your answers directly in search, before users even click. For "how is EPF calculated" type queries, this drives meaningful traffic.
The Unit Trust Fees Calculator
This one required the most research. Malaysian unit trust funds typically charge:
- Sales charge: 0–5.5% upfront, deducted from investment amount
- Annual management fee: 0.5–1.5%, charged on NAV
- Trustee fee: ~0.05%
The compounding effect of fees is genuinely surprising. A RM50,000 investment at 8% annual growth over 20 years:
- With 1.5% annual fees: ends at ~RM147,000
- With 0.3% annual fees (e.g. index fund): ends at ~RM193,000
That RM46,000 difference is the "fee drag" visualization I built into the calculator. It's the single most eye-opening output, and it's just two compound growth formulas side by side.
Input Validation Without a Library
For a single-file app, pulling in a validation library is overkill. I use the Constraint Validation API built into HTML5:
const input = document.getElementById('property-value');
input.addEventListener('input', function() {
const val = parseFloat(this.value);
if (isNaN(val) || val < 0) {
this.setCustomValidity('Please enter a positive number');
} else if (val > 100000000) {
this.setCustomValidity('Value seems unusually high — please check');
} else {
this.setCustomValidity('');
}
this.reportValidity();
});
setCustomValidity('') clears the error. reportValidity() shows it. No library, no boilerplate.
The Tip Jar Model
These calculators are free. I added a small "Buy me a coffee" link at the bottom of each page. In three months, it's generated a handful of voluntary payments from users who found the tools genuinely useful.
The lesson: if you solve a real problem well and don't add friction, some users will voluntarily support you. This works better than ads (which tank PageSpeed) or paywalls (which reduce reach).
What I'd Do Differently
Shared utility file — I duplicated
formatRM()across calculators. I should have a singleutils.jswith currency formatting, and load it once.URL-based state — Storing calculator inputs in the URL hash (e.g.
?price=600000&loan=80) would let users share specific scenarios. This is a one-day addition I keep deferring.Test the formulas — I caught two EPF edge case bugs manually. A simple Node.js test file (
node test-epf.js) with known inputs/outputs would have caught them faster.
Try the Calculators
The full suite is at Sorted MY. Stamp duty, EPF, zakat, rental yield, and unit trust fees — all free, no login, no tracking.
If you're building calculator tools for a local market, vanilla JS is genuinely the right call. The constraints force clarity, the bundle size stays tiny, and the pages rank.
What's the most complex formula you've had to implement in a static site? I'd love to compare approaches in the comments.
Top comments (0)