Build an expense tracker with HTML and JavaScript

An expense tracker is a form, an array and one function that redraws the page. This guide builds the smallest working version, then adds a budget, categories and delete.

An expense tracker in JavaScript is three parts: a form that collects a description and an amount, an array that holds every expense, and a render() function that redraws the list and total. All of it fits in one HTML file.

Try it first. Add an expense, and watch the total change.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Expense tracker</title>
<style>
  * { box-sizing: border-box; }
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  main { max-width: 440px; margin: 0 auto; background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 4px 16px rgba(0,0,0,.08); }
  form { display: flex; flex-wrap: wrap; gap: 8px; }
  input { font: inherit; padding: 8px 10px; border: 1px solid #c9cfd8; border-radius: 8px; min-width: 0; }
  #what { flex: 2 1 130px; }
  #amt { flex: 1 1 90px; }
  button { font: inherit; padding: 8px 14px; border: 0; border-radius: 8px; background: #2563eb; color: #fff; cursor: pointer; }
  ul { list-style: none; margin: 14px 0 0; padding: 0; }
  li { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #eef0f4; }
  .total { display: flex; justify-content: space-between; margin: 12px 0 0; font-size: 1.1rem; }
</style>
</head>
<body>
<main>
  <form id="form">
    <input id="what" placeholder="What for?" required>
    <input id="amt" type="number" min="0.01" step="0.01" placeholder="Amount" required>
    <button>Add</button>
  </form>
  <ul id="list"></ul>
  <p class="total"><span>Total</span><b id="total"></b></p>
</main>

<script>
  const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
  const expenses = [
    { what: 'Coffee', cents: 450 },
    { what: 'Train ticket', cents: 1280 },
  ];

  const form = document.getElementById('form');
  const list = document.getElementById('list');

  function render() {
    // rebuild the list and the total from the array, every time
    list.replaceChildren(...expenses.map((x) => {
      const li = document.createElement('li');
      const name = document.createElement('span');
      const amount = document.createElement('span');
      name.textContent = x.what;                       // text, never markup
      amount.textContent = money.format(x.cents / 100);
      li.append(name, amount);
      return li;
    }));
    const sum = expenses.reduce((s, x) => s + x.cents, 0);
    document.getElementById('total').textContent = money.format(sum / 100);
  }

  form.addEventListener('submit', (e) => {
    e.preventDefault();                                 // no page reload
    const what = document.getElementById('what').value.trim();
    const cents = Math.round(Number(document.getElementById('amt').value) * 100);
    if (!what || !(cents > 0)) return;
    expenses.push({ what, cents });                     // money kept as whole cents
    form.reset();
    render();
    document.getElementById('what').focus();
  });

  render();
</script>
</body>
</html>
A form, an array and one render function. Add a few rows, including one with a tag in its name.

How it works: a form, an array and render()

Every change follows the same four steps, and the page is always drawn from the array.

Form to array to render to page. Every change goes through the array.
Form to array to render to page. Every change goes through the array.
  1. Submit. The form's submit event fires when the user presses Add or hits Enter. Call preventDefault() first so the page does not reload.
  2. Read. Take the description with trim(), and turn the amount into whole cents.
  3. Store. Push { what, cents } into the expenses array.
  4. Draw. Call render(). It rebuilds the rows with replaceChildren and writes the total.

Because render() starts from the array every time, the page can never disagree with the data. Adding, deleting and filtering all become "change the array, call render".

The total is one reduce call:

const sum = expenses.reduce((s, x) => s + x.cents, 0);

Keep the 0 at the end. Without a starting value, reduce throws a TypeError on an empty array, and a tracker with no expenses yet is empty.

The same array-and-render pattern drives the simple HTML todo list, with a checkbox where this has an amount.

Keep money as whole cents

JavaScript numbers are binary floating point. Most decimal fractions cannot be stored exactly, so the sum of two prices can come out a hair off.

Decimals add up almost right. Whole cents add up exactly.
Decimals add up almost right. Whole cents add up exactly.

Press the buttons below. The left panel adds decimals, the right panel adds whole cents.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Decimals vs whole cents</title>
<style>
  * { box-sizing: border-box; }
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .btns { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
  button { font: inherit; padding: 8px 14px; border: 1px solid #c9cfd8; border-radius: 8px; background: #fff; cursor: pointer; }
  button.reset { margin-left: auto; }
  .panels { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 12px; }
  .panel { background: #fff; border-radius: 12px; padding: 14px; border: 2px solid; }
  .bad { border-color: #f59e0b; }
  .good { border-color: #16a34a; }
  h2 { margin: 0 0 6px; font-size: 1rem; }
  .small { margin: 0 0 8px; font-size: .85rem; color: #4b5563; }
  .val { font: 600 1.05rem ui-monospace, Consolas, monospace; overflow-wrap: anywhere; min-height: 1.5em; }
  .eq { margin: 8px 0 0; font-size: .9rem; }
  .eq b { font-family: ui-monospace, Consolas, monospace; }
  #log { margin: 14px 0 0; font-size: .9rem; color: #4b5563; }
</style>
</head>
<body>
<div class="btns">
  <button data-add="0.10">+ 0.10</button>
  <button data-add="0.20">+ 0.20</button>
  <button data-add="0.30">+ 0.30</button>
  <button class="reset" id="reset">Reset</button>
</div>

<div class="panels">
  <section class="panel bad">
    <h2>Decimals</h2>
    <p class="small">total = total + 0.1 ...</p>
    <div class="val" id="floatVal">0</div>
    <p class="eq">Equals cents / 100? <b id="floatEq">-</b></p>
  </section>
  <section class="panel good">
    <h2>Whole cents</h2>
    <p class="small">cents = cents + 10 ...</p>
    <div class="val" id="centsVal">0</div>
    <p class="eq">Shown as <b id="centsEq">-</b></p>
  </section>
</div>
<p id="log">Press + 0.10, then + 0.20, then + 0.30.</p>

<script>
  let total = 0;   // decimals
  let cents = 0;   // whole cents
  const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
  const $ = (id) => document.getElementById(id);

  function show() {
    $('floatVal').textContent = String(total);                       // the raw number
    $('centsVal').textContent = String(cents);
    $('floatEq').textContent = String(total === cents / 100);
    $('centsEq').textContent = money.format(cents / 100);
  }

  document.querySelectorAll('[data-add]').forEach((b) => {
    b.addEventListener('click', () => {
      const amount = Number(b.dataset.add);       // dataset values are strings
      total = total + amount;
      cents = cents + Math.round(amount * 100);
      show();
    });
  });

  $('reset').addEventListener('click', () => { total = 0; cents = 0; show(); });
  show();
</script>
</body>
</html>
Press + 0.10 then + 0.20. The decimal total prints with a long tail. The cents total stays 30.

The fix is to store every amount as an integer number of cents. Convert once, when the amount comes in:

const cents = Math.round(Number(amountInput.value) * 100);

The Math.round matters. Multiplying 19.99 by 100 gives 1998.9999999999998, not 1999. Math.round picks the nearest whole number, and cutting the decimals off with Math.floor would lose a cent.

Divide by 100 only when you draw the page. Not every currency has two decimals, so this step suits currencies with cents. Formatting Japanese yen, for example, gives a whole number.

Read the form without reloading the page

A form that is submitted without preventDefault() is a navigation. The browser loads a page, and everything the script built is gone. A form with no action loads its own address again, which html-form-without-action explains.

Without preventDefault the page loads again and the new row is lost. With it, the script keeps control.
Without preventDefault the page loads again and the new row is lost. With it, the script keeps control.

Three details make the form behave:

<input id="what" required>
<input id="amt" type="number" min="0.01" step="0.01" required>
  • Validation runs first. If a field fails required, min or step, the browser shows its message and no submit event fires. See form validation for the details.
  • step="0.01" allows two decimals. The default step for a number input is 1, which only accepts whole numbers.
  • value is a string. "12.5" + 1 is "12.51". Wrap it in Number(), and note that Number("") is 0, so also check that the cents are above zero.

Put the description on the page with textContent, not innerHTML. textContent treats a typed <b> as plain characters instead of markup. The first example above shows it: the tag appears as text.

Show amounts as currency

Intl.NumberFormat turns a number into a currency string. Build the formatter once and reuse it:

const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
money.format(1999 / 100);   // "$19.99"

Change the locale and currency code to match your readers, for example de-DE with EUR. The formatter picks the currency's usual number of decimals, so no toFixed is needed. The Intl.NumberFormat guide lists the options.

A finished example: budget, categories and delete

The same array scales to a fuller tracker. This version adds a budget, a bar per category, a filter and a delete button.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Expense tracker with budget</title>
<style>
  * { box-sizing: border-box; }
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  main { max-width: 520px; margin: 0 auto; background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 4px 16px rgba(0,0,0,.08); }
  h1 { margin: 0 0 12px; font-size: 1.2rem; }
  input, select, button { font: inherit; padding: 8px 10px; border: 1px solid #c9cfd8; border-radius: 8px; min-width: 0; background: #fff; }
  .sum { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
  .sum div { display: flex; flex-direction: column; gap: 2px; }
  .sum span { font-size: .78rem; color: #6b7280; }
  .sum b { font-size: 1.1rem; padding: 8px 0; }
  .sum input { width: 100%; }
  .meter { height: 10px; border-radius: 5px; background: #e5e7eb; margin: 6px 0 16px; overflow: hidden; }
  .meter i { display: block; height: 100%; width: 0; background: #16a34a; }
  .over .meter i { background: #f59e0b; }
  .over #left { color: #9a3412; }
  form { display: flex; flex-wrap: wrap; gap: 8px; }
  #what { flex: 2 1 140px; }
  #amt { flex: 1 1 90px; }
  #cat { flex: 1 1 100px; }
  form button { background: #2563eb; border-color: #2563eb; color: #fff; cursor: pointer; }
  .bars { margin: 16px 0 6px; display: grid; gap: 6px; }
  .bar { display: grid; grid-template-columns: 78px 1fr 72px; gap: 8px; align-items: center; font-size: .85rem; }
  .bar .track { height: 8px; background: #eef0f4; border-radius: 4px; overflow: hidden; }
  .bar .track i { display: block; height: 100%; background: #2563eb; }
  .bar em { font-style: normal; text-align: right; }
  .filter { margin: 14px 0 4px; font-size: .9rem; }
  ul { list-style: none; margin: 0; padding: 0; }
  li { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid #eef0f4; }
  li .what { flex: 1; min-width: 0; overflow-wrap: anywhere; }
  li .tag { font-size: .72rem; padding: 2px 8px; border-radius: 99px; background: #eef2ff; color: #3730a3; }
  li .del { padding: 2px 9px; cursor: pointer; color: #9a3412; }
  li.empty { color: #6b7280; }
</style>
</head>
<body>
<main id="app">
  <h1>Expense tracker</h1>

  <div class="sum">
    <div><span>Spent</span><b id="spent"></b></div>
    <div><span>Budget</span><input id="budget" type="number" min="0" step="1" value="300" aria-label="Budget"></div>
    <div><span id="leftLabel">Left</span><b id="left"></b></div>
  </div>
  <div class="meter"><i id="fill"></i></div>

  <form id="form">
    <input id="what" placeholder="What for?" required>
    <input id="amt" type="number" min="0.01" step="0.01" placeholder="Amount" required>
    <select id="cat" aria-label="Category"></select>
    <button>Add</button>
  </form>

  <div class="bars" id="bars"></div>

  <div class="filter">Show <select id="show" aria-label="Filter by category"></select></div>
  <ul id="list"></ul>
</main>

<script>
  const CATS = ['Food', 'Transport', 'Home', 'Fun'];
  const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
  const fmt = (cents) => money.format(cents / 100);
  const $ = (id) => document.getElementById(id);

  let nextId = 1;
  let expenses = [
    ['Groceries', 'Food', 5420], ['Metro card', 'Transport', 3000],
    ['Lamp', 'Home', 2499], ['Cinema', 'Fun', 1800], ['Lunch', 'Food', 1275],
  ].map(([what, cat, cents]) => ({ id: nextId++, what, cat, cents }));
  let filter = 'All';

  for (const c of CATS) $('cat').add(new Option(c));
  for (const c of ['All', ...CATS]) $('show').add(new Option(c));

  function render() {
    const spent = expenses.reduce((s, x) => s + x.cents, 0);
    const budget = Math.round(Number($('budget').value) * 100);   // empty box counts as 0
    const left = budget - spent;

    $('spent').textContent = fmt(spent);
    $('left').textContent = fmt(Math.abs(left));
    $('leftLabel').textContent = left < 0 ? 'Over by' : 'Left';
    $('app').classList.toggle('over', left < 0);
    $('fill').style.width = (budget > 0 ? Math.min(100, spent / budget * 100) : 100) + '%';

    // one bar per category, as a share of everything spent
    $('bars').replaceChildren(...CATS.map((c) => {
      const sum = expenses.filter((x) => x.cat === c).reduce((s, x) => s + x.cents, 0);
      const row = document.createElement('div');
      row.className = 'bar';
      row.innerHTML = '<span></span><div class="track"><i></i></div><em></em>';
      row.children[0].textContent = c;
      row.querySelector('i').style.width = (spent ? sum / spent * 100 : 0) + '%';
      row.children[2].textContent = fmt(sum);
      return row;
    }));

    const rows = filter === 'All' ? expenses : expenses.filter((x) => x.cat === filter);
    if (!rows.length) {
      const li = document.createElement('li');
      li.className = 'empty';
      li.textContent = 'Nothing here yet.';
      $('list').replaceChildren(li);
      return;
    }
    $('list').replaceChildren(...rows.map((x) => {
      const li = document.createElement('li');
      li.innerHTML = '<span class="what"></span><span class="tag"></span><b></b><button class="del" type="button">x</button>';
      li.querySelector('.what').textContent = x.what;     // user text goes in as text
      li.querySelector('.tag').textContent = x.cat;
      li.querySelector('b').textContent = fmt(x.cents);
      const del = li.querySelector('.del');
      del.dataset.id = x.id;
      del.setAttribute('aria-label', 'Delete ' + x.what);
      return li;
    }));
  }

  $('form').addEventListener('submit', (e) => {
    e.preventDefault();
    const what = $('what').value.trim();
    const cents = Math.round(Number($('amt').value) * 100);
    if (!what || !(cents > 0)) return;
    expenses.push({ id: nextId++, what, cat: $('cat').value, cents });
    $('what').value = '';
    $('amt').value = '';
    render();
    $('what').focus();
  });

  // one listener for every delete button, including rows added later
  $('list').addEventListener('click', (e) => {
    const btn = e.target.closest('button[data-id]');
    if (!btn) return;
    const id = Number(btn.dataset.id);                    // dataset gives a string
    expenses = expenses.filter((x) => x.id !== id);
    render();
  });

  $('show').addEventListener('change', () => { filter = $('show').value; render(); });
  $('budget').addEventListener('input', render);
  render();
</script>
</body>
</html>
Change the budget, add a large expense to go over it, filter by category, delete a row.
  • Budget. Left is the budget minus the sum. When it goes negative, the label reads "Over by" and the meter turns orange.
  • Categories. Each bar is the sum of one category as a percentage of everything spent, set as the bar's width. No chart library is involved.
  • Ids. Each expense gets an id from a counter, so delete finds the right row even while the list is filtered.
  • Delete. One click listener on the ul serves every button, including rows added later.
list.addEventListener('click', (e) => {
  const btn = e.target.closest('button[data-id]');
  if (!btn) return;
  expenses = expenses.filter((x) => x.id !== Number(btn.dataset.id));
  render();
});

closest() starts at the clicked element and walks up. It returns null when the click was not on a delete button.

dataset values are always strings, so convert the id with Number() before comparing it with a number.

The array lives in memory, so a reload empties it. Rows kept between visits need local storage, which is left out here because storage resets on reload in a NOS page. The single HTML file apps guide covers the trade-offs of this style.

When it does not work

What you see Cause Fix
The new row vanishes and the address gains ?what=... The form was submitted normally Call e.preventDefault() in the submit handler
Total shows 0.30000000000000004 Amounts are decimals Store cents, format on display
Total reads "1010" instead of 20 + joined two strings Wrap input values in Number()
12.50 is refused with a validation message A number input steps by 1 by default Add step="0.01"
Total is a cent short Math.floor cut the decimals Use Math.round
Total throws a TypeError on an empty list reduce has no starting value Pass 0 as the second argument
A typed tag turns text bold The text went in with innerHTML Use textContent
Delete button does nothing A string id was compared with a number id Convert with Number(btn.dataset.id)
Rows disappear on reload The array lives in memory Expected; see the storage note above

A tracker is easier to show than to describe, because the person you send it to can type their own expenses and watch the total move.

Paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so anyone with the link can open it without an account and try it. If you edit the code later, the same link shows the new version.

Questions people ask

Do I need a framework or a server to build an expense tracker in JavaScript?

No. A form, an array and a render function in one HTML file are enough, and the file runs by opening it. A server only becomes useful when the expenses must be saved and read from more than one device.

Why store amounts as cents instead of decimals?

JavaScript numbers are binary floating point, and most decimal fractions cannot be stored exactly. Adding 0.1 and 0.2 gives 0.30000000000000004. Whole numbers of cents add exactly, and you divide by 100 only when you show the amount.

How do I keep the expenses after a page reload?

The array lives in memory, so a reload empties it. The browser feature for keeping small data between visits is local storage. In a NOS page, storage resets on reload, so a shared tracker starts from the rows written in the file.

How do I change the currency?

Change the locale and currency code in the Intl.NumberFormat call, for example de-DE with EUR. The same format function then draws every amount, so nothing else in the code changes.

Can I add a chart?

Yes. The finished example draws one bar per category by setting each bar's width to a percentage of the total, which needs no chart library. The html-bar-chart guide covers the technique.

Keep reading