Budget Tracker Card — Spending by Category HTML/CSS/JS

Budget Tracker Card · Dashboards · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Single data array drives the whole card
Every bar, label, and color comes from CATEGORIES and BUDGET_CAP — no per-field DOM updates to maintain.
Three-state overall progress bar
Indigo under 80%, amber from 80–100%, red once over — both the fill color and the label text change.
Independent per-category over-budget flag
A category can flag red even while the overall total is healthy, matching how real budgets actually behave.
Capped fill width
Math.min(100, …) prevents an over-budget bar from visually overflowing its track.
Automatic remaining/over-budget label
The label text itself switches wording based on whether you're under or over, not just the color.
Color-coded category dots
Each category has its own accent color, carried through to its dot and progress fill for quick scanning.
Smooth width-only transitions
Bar fills animate via width with a cubic-bezier ease — stable in every export target since the track size never changes.
Currency formatting
Totals use toLocaleString() for comma-separated thousands, reading like real currency instead of a raw number.

About this UI Snippet

Budget Tracker Card — Overall Progress, Per-Category Caps & Over-Budget Warnings

Screenshot of the Budget Tracker Card snippet rendered live

A budget tracker only earns trust if it's honest about overspending — a green bar that quietly turns red is far more useful than a generic progress indicator. This snippet builds a monthly budget card with an overall spend bar that shifts from indigo to amber to red as it fills, plus a per-category breakdown that flags any category individually over its own cap, all driven by one small data array.

One data array drives everything

CATEGORIES is a plain array of { name, color, spent, cap } objects, and BUDGET_CAP is the single overall monthly limit. render() sums every category's spent for the total, computes the overall percentage against BUDGET_CAP, and rebuilds the category list — so updating a single number anywhere in the data instantly recomputes the whole card with one function call, no manual DOM bookkeeping per field.

Three-state overall bar

The top progress bar's fill width is the spend percentage capped at 100% (so an over-budget month doesn't overflow the track), and its color escalates through three CSS classes: the default indigo-to-purple gradient under 80% used, an amber-to-orange .warn gradient from 80–100%, and a red .over gradient once total spend exceeds the cap. The label beneath switches wording too — "$X left this month" while under budget, "$X over budget" once it isn't — so the message matches the math instead of always reading as a vague percentage.

Per-category caps, independent of the overall total

Each category has its own cap, and a category can go over its own cap (shown in red, with the row's amount text turning red via an .over class) even while the *overall* budget is still healthy — exactly how real budgeting works: groceries can run over while the month as a whole is fine because other categories came in under. This nuance is what makes a per-category breakdown more useful than a single combined bar.

Smooth, export-safe fills

Every bar fill transitions only its width with a cubic-bezier ease — width transitions on a fixed-height track are stable in every export target because the track's own dimensions never change, only the inner fill's width, so there's no layout-shift risk the way animating a container's height would have.

Connecting real transaction data

The numbers here are static placeholders meant to demonstrate the visual states; a production budget tracker sums real transactions grouped by category, typically from a bank-linking API (Plaid and similar providers) or a manually logged expense table. Because every visual — bar width, color, and label wording — is recomputed from CATEGORIES and BUDGET_CAP inside a single render() call, swapping the data source is a one-line change: replace the static spent values with a sum of that period's transactions per category, call render() again, and the card's entire visual state (including which categories flip to the over-budget red) updates correctly with no other code to touch.

Step by step

How to Use

  1. 1
    Paste HTML, CSS, and JSA budget card renders with an overall progress bar and five category rows (Groceries, Dining, Transport, Entertainment, Shopping).
  2. 2
    Read the overall barIts color and the label beneath show whether you're under budget, in the 80%+ warning zone, or over for the month.
  3. 3
    Check each categoryCategories over their own cap show in red even if the overall budget is still healthy — read each row independently.
  4. 4
    Edit the dataChange any spent or cap value in the CATEGORIES array, or BUDGET_CAP, and call render() to see every bar and label recompute.
  5. 5
    Add or remove categoriesPush or remove an object in CATEGORIES — the category list and overall total both adjust automatically.
  6. 6
    Connect real transaction dataReplace the static spent values with a sum of your transaction API's amounts grouped by category, then call render() after each fetch.

Real-world uses

Common Use Cases

Personal finance dashboards
The core "how am I doing this month" card for budgeting apps — pair with a pomodoro timer or habit widgets for a full personal dashboard.
Expense management tools
Show team or department spend against allocated category budgets in an internal finance tool.
Subscription and SaaS usage limits
Repurpose the per-category bars for usage-vs-quota tracking (API calls, storage, seats) instead of money.
Banking and fintech apps
A familiar, trustworthy spend-tracking card for a bank or budgeting app's home screen.
Project budget tracking
Use categories as project line items (materials, labor, contingency) instead of personal spending categories.
Learning data-driven progress UI
A clean example of deriving every visual state (color, width, label) from a small data model — compare with a gradient progress bar for a simpler single-value case.

Got questions?

Frequently Asked Questions

Fetch your transactions, group and sum them by category into the same { name, color, spent, cap } shape used by CATEGORIES, assign the result to CATEGORIES, and call render() — every bar, label, and color recomputes from the new totals.

Edit the thresholds in render(): the ".warn" class is added when pct > 80, and ".over" when totalSpent > BUDGET_CAP — change 80 to whatever warning threshold fits your use case.

Add an input bound to BUDGET_CAP (or each category's cap) and call render() on change — the existing percentage math and color thresholds will reflect the new limit immediately.

The card has no built-in time period logic — it just compares spent against cap. Swap BUDGET_CAP and the category caps for your daily/weekly limits, and update the header text ("June 2026") to match your period.

In React, keep categories and budgetCap in state and compute percentages with useMemo, rendering rows with .map(); in Vue, use a computed categories array with v-for; in Angular, use *ngFor with a getter for percentages. The color-threshold logic ports directly into each framework's conditional class binding.

Budget Tracker Card — Export as HTML, React, Vue, Angular & Tailwind

HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Budget Tracker Card</title>
  <style>
    *{box-sizing:border-box;margin:0;padding:0}
    body{font-family:system-ui,-apple-system,sans-serif;background:#f1f5f9;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
    
    .bt-card{background:#fff;border-radius:18px;padding:22px;width:100%;max-width:420px;box-shadow:0 18px 44px rgba(15,23,42,.1)}
    .bt-head{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:14px}
    .bt-head h3{font-size:16px;font-weight:800;color:#0f172a}
    .bt-head p{font-size:12px;color:#94a3b8;margin-top:2px}
    .bt-total{text-align:right}
    .bt-total span{font-size:18px;font-weight:800;color:#0f172a;font-variant-numeric:tabular-nums}
    .bt-of{font-size:12px;font-weight:600;color:#94a3b8}
    
    .bt-overall{margin-bottom:18px}
    .bt-overall-track{height:9px;background:#f1f5f9;border-radius:999px;overflow:hidden}
    .bt-overall-fill{height:100%;width:0;border-radius:999px;background:linear-gradient(90deg,#6366f1,#8b5cf6);transition:width .6s cubic-bezier(.4,0,.2,1)}
    .bt-overall-fill.warn{background:linear-gradient(90deg,#f59e0b,#f97316)}
    .bt-overall-fill.over{background:linear-gradient(90deg,#ef4444,#dc2626)}
    .bt-overall-label{display:block;margin-top:6px;font-size:11.5px;font-weight:600;color:#64748b}
    
    .bt-cats{display:flex;flex-direction:column;gap:13px}
    .bt-cat-row{display:flex;flex-direction:column;gap:5px}
    .bt-cat-top{display:flex;align-items:center;justify-content:space-between;font-size:13px}
    .bt-cat-name{display:flex;align-items:center;gap:7px;font-weight:700;color:#1e293b}
    .bt-cat-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
    .bt-cat-amt{font-weight:700;color:#475569;font-variant-numeric:tabular-nums;font-size:12.5px}
    .bt-cat-amt .bt-cap{color:#cbd5e1;font-weight:600}
    .bt-cat-track{height:6px;background:#f1f5f9;border-radius:999px;overflow:hidden}
    .bt-cat-fill{height:100%;border-radius:999px;transition:width .6s cubic-bezier(.4,0,.2,1)}
    .bt-cat-row.over .bt-cat-amt{color:#dc2626}
  </style>
</head>
<body>
  <div class="bt-card">
    <div class="bt-head">
      <div>
        <h3>Monthly budget</h3>
        <p>June 2026</p>
      </div>
      <div class="bt-total">
        <span id="btSpent">$0</span> <span class="bt-of">of $2,400</span>
      </div>
    </div>
  
    <div class="bt-overall">
      <div class="bt-overall-track"><div class="bt-overall-fill" id="btOverallFill"></div></div>
      <span class="bt-overall-label" id="btOverallLabel"></span>
    </div>
  
    <div class="bt-cats" id="btCats"></div>
  </div>
  <script>
    var CATEGORIES = [
      { name: 'Groceries', color: '#22c55e', spent: 380, cap: 500 },
      { name: 'Dining out', color: '#f59e0b', spent: 265, cap: 250 },
      { name: 'Transport', color: '#6366f1', spent: 140, cap: 300 },
      { name: 'Entertainment', color: '#ec4899', spent: 95, cap: 150 },
      { name: 'Shopping', color: '#0ea5e9', spent: 410, cap: 400 },
    ];
    var BUDGET_CAP = 2400;
    
    function render() {
      var totalSpent = CATEGORIES.reduce(function (s, c) { return s + c.spent; }, 0);
      document.getElementById('btSpent').textContent = '$' + totalSpent.toLocaleString();
    
      var pct = Math.min(100, (totalSpent / BUDGET_CAP) * 100);
      var overallFill = document.getElementById('btOverallFill');
      overallFill.style.width = pct + '%';
      overallFill.className = 'bt-overall-fill' + (totalSpent > BUDGET_CAP ? ' over' : pct > 80 ? ' warn' : '');
    
      var remaining = BUDGET_CAP - totalSpent;
      document.getElementById('btOverallLabel').textContent = remaining >= 0
        ? '$' + remaining.toLocaleString() + ' left this month (' + Math.round(pct) + '% used)'
        : '$' + Math.abs(remaining).toLocaleString() + ' over budget';
    
      var cats = document.getElementById('btCats');
      cats.innerHTML = CATEGORIES.map(function (c) {
        var catPct = Math.min(100, (c.spent / c.cap) * 100);
        var over = c.spent > c.cap;
        return '<div class="bt-cat-row' + (over ? ' over' : '') + '">' +
          '<div class="bt-cat-top">' +
            '<span class="bt-cat-name"><i class="bt-cat-dot" style="background:' + c.color + '"></i>' + c.name + '</span>' +
            '<span class="bt-cat-amt">$' + c.spent + ' <span class="bt-cap">/ $' + c.cap + '</span></span>' +
          '</div>' +
          '<div class="bt-cat-track"><div class="bt-cat-fill" style="width:' + catPct + '%;background:' + (over ? '#ef4444' : c.color) + '"></div></div>' +
        '</div>';
      }).join('');
    }
    
    render();
  </script>
</body>
</html>
Tailwind
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Budget Tracker Card</title>
  <!-- Tailwind CSS v3+ — arbitrary value classes (e.g. bg-[#0f172a]) are valid -->
  <script src="https://cdn.tailwindcss.com"></script>
  <style>
    * {
      box-sizing:border-box;margin:0;padding:0
    }

    body {
      font-family:system-ui,-apple-system,sans-serif;background:#f1f5f9;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px
    }

    .bt-head h3 {
      font-size:16px;font-weight:800;color:#0f172a
    }

    .bt-head p {
      font-size:12px;color:#94a3b8;margin-top:2px
    }

    .bt-total span {
      font-size:18px;font-weight:800;color:#0f172a;font-variant-numeric:tabular-nums
    }

    .bt-overall-fill {
      height:100%;width:0;border-radius:999px;background:linear-gradient(90deg,#6366f1,#8b5cf6);transition:width .6s cubic-bezier(.4,0,.2,1)
    }

    .bt-overall-fill.warn {
      background:linear-gradient(90deg,#f59e0b,#f97316)
    }

    .bt-overall-fill.over {
      background:linear-gradient(90deg,#ef4444,#dc2626)
    }

    .bt-cat-row {
      display:flex;flex-direction:column;gap:5px
    }

    .bt-cat-top {
      display:flex;align-items:center;justify-content:space-between;font-size:13px
    }

    .bt-cat-name {
      display:flex;align-items:center;gap:7px;font-weight:700;color:#1e293b
    }

    .bt-cat-dot {
      width:8px;height:8px;border-radius:50%;flex-shrink:0
    }

    .bt-cat-amt {
      font-weight:700;color:#475569;font-variant-numeric:tabular-nums;font-size:12.5px
    }

    .bt-cat-amt .bt-cap {
      color:#cbd5e1;font-weight:600
    }

    .bt-cat-track {
      height:6px;background:#f1f5f9;border-radius:999px;overflow:hidden
    }

    .bt-cat-fill {
      height:100%;border-radius:999px;transition:width .6s cubic-bezier(.4,0,.2,1)
    }

    .bt-cat-row.over .bt-cat-amt {
      color:#dc2626
    }
  </style>
</head>
<body>
  <div class="bg-[#fff] rounded-[18px] p-[22px] w-full max-w-[420px] shadow-[0_18px_44px_rgba(15,23,42,.1)]">
    <div class="bt-head flex items-start justify-between mb-3.5">
      <div>
        <h3>Monthly budget</h3>
        <p>June 2026</p>
      </div>
      <div class="bt-total text-right">
        <span id="btSpent">$0</span> <span class="text-xs font-semibold text-[#94a3b8]">of $2,400</span>
      </div>
    </div>
  
    <div class="mb-[18px]">
      <div class="h-[9px] bg-[#f1f5f9] rounded-[999px] overflow-hidden"><div class="bt-overall-fill" id="btOverallFill"></div></div>
      <span class="block mt-1.5 text-[11.5px] font-semibold text-[#64748b]" id="btOverallLabel"></span>
    </div>
  
    <div class="flex flex-col gap-[13px]" id="btCats"></div>
  </div>
  <script>
    var CATEGORIES = [
      { name: 'Groceries', color: '#22c55e', spent: 380, cap: 500 },
      { name: 'Dining out', color: '#f59e0b', spent: 265, cap: 250 },
      { name: 'Transport', color: '#6366f1', spent: 140, cap: 300 },
      { name: 'Entertainment', color: '#ec4899', spent: 95, cap: 150 },
      { name: 'Shopping', color: '#0ea5e9', spent: 410, cap: 400 },
    ];
    var BUDGET_CAP = 2400;
    
    function render() {
      var totalSpent = CATEGORIES.reduce(function (s, c) { return s + c.spent; }, 0);
      document.getElementById('btSpent').textContent = '$' + totalSpent.toLocaleString();
    
      var pct = Math.min(100, (totalSpent / BUDGET_CAP) * 100);
      var overallFill = document.getElementById('btOverallFill');
      overallFill.style.width = pct + '%';
      overallFill.className = 'bt-overall-fill' + (totalSpent > BUDGET_CAP ? ' over' : pct > 80 ? ' warn' : '');
    
      var remaining = BUDGET_CAP - totalSpent;
      document.getElementById('btOverallLabel').textContent = remaining >= 0
        ? '$' + remaining.toLocaleString() + ' left this month (' + Math.round(pct) + '% used)'
        : '$' + Math.abs(remaining).toLocaleString() + ' over budget';
    
      var cats = document.getElementById('btCats');
      cats.innerHTML = CATEGORIES.map(function (c) {
        var catPct = Math.min(100, (c.spent / c.cap) * 100);
        var over = c.spent > c.cap;
        return '<div class="bt-cat-row' + (over ? ' over' : '') + '">' +
          '<div class="bt-cat-top">' +
            '<span class="bt-cat-name"><i class="bt-cat-dot" style="background:' + c.color + '"></i>' + c.name + '</span>' +
            '<span class="bt-cat-amt">$' + c.spent + ' <span class="bt-cap">/ $' + c.cap + '</span></span>' +
          '</div>' +
          '<div class="bt-cat-track"><div class="bt-cat-fill" style="width:' + catPct + '%;background:' + (over ? '#ef4444' : c.color) + '"></div></div>' +
        '</div>';
      }).join('');
    }
    
    render();
  </script>
</body>
</html>
React
import React, { useEffect } from 'react';

// CSS — optionally move to BudgetTrackerCard.module.css
const css = `
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#f1f5f9;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}

.bt-card{background:#fff;border-radius:18px;padding:22px;width:100%;max-width:420px;box-shadow:0 18px 44px rgba(15,23,42,.1)}
.bt-head{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:14px}
.bt-head h3{font-size:16px;font-weight:800;color:#0f172a}
.bt-head p{font-size:12px;color:#94a3b8;margin-top:2px}
.bt-total{text-align:right}
.bt-total span{font-size:18px;font-weight:800;color:#0f172a;font-variant-numeric:tabular-nums}
.bt-of{font-size:12px;font-weight:600;color:#94a3b8}

.bt-overall{margin-bottom:18px}
.bt-overall-track{height:9px;background:#f1f5f9;border-radius:999px;overflow:hidden}
.bt-overall-fill{height:100%;width:0;border-radius:999px;background:linear-gradient(90deg,#6366f1,#8b5cf6);transition:width .6s cubic-bezier(.4,0,.2,1)}
.bt-overall-fill.warn{background:linear-gradient(90deg,#f59e0b,#f97316)}
.bt-overall-fill.over{background:linear-gradient(90deg,#ef4444,#dc2626)}
.bt-overall-label{display:block;margin-top:6px;font-size:11.5px;font-weight:600;color:#64748b}

.bt-cats{display:flex;flex-direction:column;gap:13px}
.bt-cat-row{display:flex;flex-direction:column;gap:5px}
.bt-cat-top{display:flex;align-items:center;justify-content:space-between;font-size:13px}
.bt-cat-name{display:flex;align-items:center;gap:7px;font-weight:700;color:#1e293b}
.bt-cat-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.bt-cat-amt{font-weight:700;color:#475569;font-variant-numeric:tabular-nums;font-size:12.5px}
.bt-cat-amt .bt-cap{color:#cbd5e1;font-weight:600}
.bt-cat-track{height:6px;background:#f1f5f9;border-radius:999px;overflow:hidden}
.bt-cat-fill{height:100%;border-radius:999px;transition:width .6s cubic-bezier(.4,0,.2,1)}
.bt-cat-row.over .bt-cat-amt{color:#dc2626}
`;

export default function BudgetTrackerCard() {
  // Auto-generated escape hatch: the original snippet's vanilla JS runs once
  // after mount and queries the rendered DOM. For idiomatic React, lift this
  // into state + handlers.
  useEffect(() => {
    const _listeners = [];
    const _originalAddEventListener = EventTarget.prototype.addEventListener;
    EventTarget.prototype.addEventListener = function(type, listener, options) {
      _listeners.push({ target: this, type, listener, options });
      return _originalAddEventListener.call(this, type, listener, options);
    };
    var CATEGORIES = [
      { name: 'Groceries', color: '#22c55e', spent: 380, cap: 500 },
      { name: 'Dining out', color: '#f59e0b', spent: 265, cap: 250 },
      { name: 'Transport', color: '#6366f1', spent: 140, cap: 300 },
      { name: 'Entertainment', color: '#ec4899', spent: 95, cap: 150 },
      { name: 'Shopping', color: '#0ea5e9', spent: 410, cap: 400 },
    ];
    var BUDGET_CAP = 2400;
    
    function render() {
      var totalSpent = CATEGORIES.reduce(function (s, c) { return s + c.spent; }, 0);
      document.getElementById('btSpent').textContent = '$' + totalSpent.toLocaleString();
    
      var pct = Math.min(100, (totalSpent / BUDGET_CAP) * 100);
      var overallFill = document.getElementById('btOverallFill');
      overallFill.style.width = pct + '%';
      overallFill.className = 'bt-overall-fill' + (totalSpent > BUDGET_CAP ? ' over' : pct > 80 ? ' warn' : '');
    
      var remaining = BUDGET_CAP - totalSpent;
      document.getElementById('btOverallLabel').textContent = remaining >= 0
        ? '$' + remaining.toLocaleString() + ' left this month (' + Math.round(pct) + '% used)'
        : '$' + Math.abs(remaining).toLocaleString() + ' over budget';
    
      var cats = document.getElementById('btCats');
      cats.innerHTML = CATEGORIES.map(function (c) {
        var catPct = Math.min(100, (c.spent / c.cap) * 100);
        var over = c.spent > c.cap;
        return '<div class="bt-cat-row' + (over ? ' over' : '') + '">' +
          '<div class="bt-cat-top">' +
            '<span class="bt-cat-name"><i class="bt-cat-dot" style="background:' + c.color + '"></i>' + c.name + '</span>' +
            '<span class="bt-cat-amt">$' + c.spent + ' <span class="bt-cap">/ $' + c.cap + '</span></span>' +
          '</div>' +
          '<div class="bt-cat-track"><div class="bt-cat-fill" style="width:' + catPct + '%;background:' + (over ? '#ef4444' : c.color) + '"></div></div>' +
        '</div>';
      }).join('');
    }
    
    render();
    // Cleanup: restore addEventListener and remove all listeners
    return () => {
      EventTarget.prototype.addEventListener = _originalAddEventListener;
      for (const { target, type, listener, options } of _listeners) {
        target.removeEventListener(type, listener, options);
      }
    };
  }, []);

  return (
    <>
      <style>{css}</style>
      <div className="bt-card">
        <div className="bt-head">
          <div>
            <h3>Monthly budget</h3>
            <p>June 2026</p>
          </div>
          <div className="bt-total">
            <span id="btSpent">$0</span> <span className="bt-of">of $2,400</span>
          </div>
        </div>
      
        <div className="bt-overall">
          <div className="bt-overall-track"><div className="bt-overall-fill" id="btOverallFill"></div></div>
          <span className="bt-overall-label" id="btOverallLabel"></span>
        </div>
      
        <div className="bt-cats" id="btCats"></div>
      </div>
    </>
  );
}
React + Tailwind
import React, { useEffect } from 'react';
// Requires Tailwind CSS v3+ — https://tailwindcss.com/docs/installation
// Arbitrary value classes (e.g. bg-[#0f172a]) are valid Tailwind v3+

export default function BudgetTrackerCard() {
  // Auto-generated escape hatch: the original snippet's vanilla JS runs once
  // after mount and queries the rendered DOM. For idiomatic React, lift this
  // into state + handlers.
  useEffect(() => {
    const _listeners = [];
    const _originalAddEventListener = EventTarget.prototype.addEventListener;
    EventTarget.prototype.addEventListener = function(type, listener, options) {
      _listeners.push({ target: this, type, listener, options });
      return _originalAddEventListener.call(this, type, listener, options);
    };
    var CATEGORIES = [
      { name: 'Groceries', color: '#22c55e', spent: 380, cap: 500 },
      { name: 'Dining out', color: '#f59e0b', spent: 265, cap: 250 },
      { name: 'Transport', color: '#6366f1', spent: 140, cap: 300 },
      { name: 'Entertainment', color: '#ec4899', spent: 95, cap: 150 },
      { name: 'Shopping', color: '#0ea5e9', spent: 410, cap: 400 },
    ];
    var BUDGET_CAP = 2400;
    
    function render() {
      var totalSpent = CATEGORIES.reduce(function (s, c) { return s + c.spent; }, 0);
      document.getElementById('btSpent').textContent = '$' + totalSpent.toLocaleString();
    
      var pct = Math.min(100, (totalSpent / BUDGET_CAP) * 100);
      var overallFill = document.getElementById('btOverallFill');
      overallFill.style.width = pct + '%';
      overallFill.className = 'bt-overall-fill' + (totalSpent > BUDGET_CAP ? ' over' : pct > 80 ? ' warn' : '');
    
      var remaining = BUDGET_CAP - totalSpent;
      document.getElementById('btOverallLabel').textContent = remaining >= 0
        ? '$' + remaining.toLocaleString() + ' left this month (' + Math.round(pct) + '% used)'
        : '$' + Math.abs(remaining).toLocaleString() + ' over budget';
    
      var cats = document.getElementById('btCats');
      cats.innerHTML = CATEGORIES.map(function (c) {
        var catPct = Math.min(100, (c.spent / c.cap) * 100);
        var over = c.spent > c.cap;
        return '<div class="bt-cat-row' + (over ? ' over' : '') + '">' +
          '<div class="bt-cat-top">' +
            '<span class="bt-cat-name"><i class="bt-cat-dot" style="background:' + c.color + '"></i>' + c.name + '</span>' +
            '<span class="bt-cat-amt">$' + c.spent + ' <span class="bt-cap">/ $' + c.cap + '</span></span>' +
          '</div>' +
          '<div class="bt-cat-track"><div class="bt-cat-fill" style="width:' + catPct + '%;background:' + (over ? '#ef4444' : c.color) + '"></div></div>' +
        '</div>';
      }).join('');
    }
    
    render();
    // Cleanup: restore addEventListener and remove all listeners
    return () => {
      EventTarget.prototype.addEventListener = _originalAddEventListener;
      for (const { target, type, listener, options } of _listeners) {
        target.removeEventListener(type, listener, options);
      }
    };
  }, []);

  return (
    <>
      <style>{`
* {
  box-sizing:border-box;margin:0;padding:0
}

body {
  font-family:system-ui,-apple-system,sans-serif;background:#f1f5f9;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px
}

.bt-head h3 {
  font-size:16px;font-weight:800;color:#0f172a
}

.bt-head p {
  font-size:12px;color:#94a3b8;margin-top:2px
}

.bt-total span {
  font-size:18px;font-weight:800;color:#0f172a;font-variant-numeric:tabular-nums
}

.bt-overall-fill {
  height:100%;width:0;border-radius:999px;background:linear-gradient(90deg,#6366f1,#8b5cf6);transition:width .6s cubic-bezier(.4,0,.2,1)
}

.bt-overall-fill.warn {
  background:linear-gradient(90deg,#f59e0b,#f97316)
}

.bt-overall-fill.over {
  background:linear-gradient(90deg,#ef4444,#dc2626)
}

.bt-cat-row {
  display:flex;flex-direction:column;gap:5px
}

.bt-cat-top {
  display:flex;align-items:center;justify-content:space-between;font-size:13px
}

.bt-cat-name {
  display:flex;align-items:center;gap:7px;font-weight:700;color:#1e293b
}

.bt-cat-dot {
  width:8px;height:8px;border-radius:50%;flex-shrink:0
}

.bt-cat-amt {
  font-weight:700;color:#475569;font-variant-numeric:tabular-nums;font-size:12.5px
}

.bt-cat-amt .bt-cap {
  color:#cbd5e1;font-weight:600
}

.bt-cat-track {
  height:6px;background:#f1f5f9;border-radius:999px;overflow:hidden
}

.bt-cat-fill {
  height:100%;border-radius:999px;transition:width .6s cubic-bezier(.4,0,.2,1)
}

.bt-cat-row.over .bt-cat-amt {
  color:#dc2626
}
      `}</style>
      <div className="bg-[#fff] rounded-[18px] p-[22px] w-full max-w-[420px] shadow-[0_18px_44px_rgba(15,23,42,.1)]">
        <div className="bt-head flex items-start justify-between mb-3.5">
          <div>
            <h3>Monthly budget</h3>
            <p>June 2026</p>
          </div>
          <div className="bt-total text-right">
            <span id="btSpent">$0</span> <span className="text-xs font-semibold text-[#94a3b8]">of $2,400</span>
          </div>
        </div>
      
        <div className="mb-[18px]">
          <div className="h-[9px] bg-[#f1f5f9] rounded-[999px] overflow-hidden"><div className="bt-overall-fill" id="btOverallFill"></div></div>
          <span className="block mt-1.5 text-[11.5px] font-semibold text-[#64748b]" id="btOverallLabel"></span>
        </div>
      
        <div className="flex flex-col gap-[13px]" id="btCats"></div>
      </div>
    </>
  );
}
Vue
<template>
  <div class="bt-card">
    <div class="bt-head">
      <div>
        <h3>Monthly budget</h3>
        <p>June 2026</p>
      </div>
      <div class="bt-total">
        <span id="btSpent">$0</span> <span class="bt-of">of $2,400</span>
      </div>
    </div>
  
    <div class="bt-overall">
      <div class="bt-overall-track"><div class="bt-overall-fill" id="btOverallFill"></div></div>
      <span class="bt-overall-label" id="btOverallLabel"></span>
    </div>
  
    <div class="bt-cats" id="btCats"></div>
  </div>
</template>

<script setup>
import { onMounted } from 'vue';

var CATEGORIES = [
  { name: 'Groceries', color: '#22c55e', spent: 380, cap: 500 },
  { name: 'Dining out', color: '#f59e0b', spent: 265, cap: 250 },
  { name: 'Transport', color: '#6366f1', spent: 140, cap: 300 },
  { name: 'Entertainment', color: '#ec4899', spent: 95, cap: 150 },
  { name: 'Shopping', color: '#0ea5e9', spent: 410, cap: 400 },
];
var BUDGET_CAP = 2400;
function render() {
  var totalSpent = CATEGORIES.reduce(function (s, c) { return s + c.spent; }, 0);
  document.getElementById('btSpent').textContent = '$' + totalSpent.toLocaleString();

  var pct = Math.min(100, (totalSpent / BUDGET_CAP) * 100);
  var overallFill = document.getElementById('btOverallFill');
  overallFill.style.width = pct + '%';
  overallFill.className = 'bt-overall-fill' + (totalSpent > BUDGET_CAP ? ' over' : pct > 80 ? ' warn' : '');

  var remaining = BUDGET_CAP - totalSpent;
  document.getElementById('btOverallLabel').textContent = remaining >= 0
    ? '$' + remaining.toLocaleString() + ' left this month (' + Math.round(pct) + '% used)'
    : '$' + Math.abs(remaining).toLocaleString() + ' over budget';

  var cats = document.getElementById('btCats');
  cats.innerHTML = CATEGORIES.map(function (c) {
    var catPct = Math.min(100, (c.spent / c.cap) * 100);
    var over = c.spent > c.cap;
    return '<div class="bt-cat-row' + (over ? ' over' : '') + '">' +
      '<div class="bt-cat-top">' +
        '<span class="bt-cat-name"><i class="bt-cat-dot" style="background:' + c.color + '"></i>' + c.name + '</span>' +
        '<span class="bt-cat-amt">$' + c.spent + ' <span class="bt-cap">/ $' + c.cap + '</span></span>' +
      '</div>' +
      '<div class="bt-cat-track"><div class="bt-cat-fill" style="width:' + catPct + '%;background:' + (over ? '#ef4444' : c.color) + '"></div></div>' +
    '</div>';
  }).join('');
}

onMounted(() => {
  render();
});
</script>

<style scoped>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#f1f5f9;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}

.bt-card{background:#fff;border-radius:18px;padding:22px;width:100%;max-width:420px;box-shadow:0 18px 44px rgba(15,23,42,.1)}
.bt-head{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:14px}
.bt-head h3{font-size:16px;font-weight:800;color:#0f172a}
.bt-head p{font-size:12px;color:#94a3b8;margin-top:2px}
.bt-total{text-align:right}
.bt-total span{font-size:18px;font-weight:800;color:#0f172a;font-variant-numeric:tabular-nums}
.bt-of{font-size:12px;font-weight:600;color:#94a3b8}

.bt-overall{margin-bottom:18px}
.bt-overall-track{height:9px;background:#f1f5f9;border-radius:999px;overflow:hidden}
.bt-overall-fill{height:100%;width:0;border-radius:999px;background:linear-gradient(90deg,#6366f1,#8b5cf6);transition:width .6s cubic-bezier(.4,0,.2,1)}
.bt-overall-fill.warn{background:linear-gradient(90deg,#f59e0b,#f97316)}
.bt-overall-fill.over{background:linear-gradient(90deg,#ef4444,#dc2626)}
.bt-overall-label{display:block;margin-top:6px;font-size:11.5px;font-weight:600;color:#64748b}

.bt-cats{display:flex;flex-direction:column;gap:13px}
.bt-cat-row{display:flex;flex-direction:column;gap:5px}
.bt-cat-top{display:flex;align-items:center;justify-content:space-between;font-size:13px}
.bt-cat-name{display:flex;align-items:center;gap:7px;font-weight:700;color:#1e293b}
.bt-cat-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.bt-cat-amt{font-weight:700;color:#475569;font-variant-numeric:tabular-nums;font-size:12.5px}
.bt-cat-amt .bt-cap{color:#cbd5e1;font-weight:600}
.bt-cat-track{height:6px;background:#f1f5f9;border-radius:999px;overflow:hidden}
.bt-cat-fill{height:100%;border-radius:999px;transition:width .6s cubic-bezier(.4,0,.2,1)}
.bt-cat-row.over .bt-cat-amt{color:#dc2626}
</style>
Angular
// @ts-nocheck
// Note: vanilla JS DOM manipulation is preserved as-is inside ngAfterViewInit().
// For idiomatic Angular, replace document.getElementById() with @ViewChild() refs
// and move state into component properties with two-way binding.
import { Component, AfterViewInit, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-budget-tracker-card',
  standalone: true,
  imports: [CommonModule],
  encapsulation: ViewEncapsulation.None,
  template: `
    <div class="bt-card">
      <div class="bt-head">
        <div>
          <h3>Monthly budget</h3>
          <p>June 2026</p>
        </div>
        <div class="bt-total">
          <span id="btSpent">$0</span> <span class="bt-of">of $2,400</span>
        </div>
      </div>
    
      <div class="bt-overall">
        <div class="bt-overall-track"><div class="bt-overall-fill" id="btOverallFill"></div></div>
        <span class="bt-overall-label" id="btOverallLabel"></span>
      </div>
    
      <div class="bt-cats" id="btCats"></div>
    </div>
  `,
  styles: [`
    *{box-sizing:border-box;margin:0;padding:0}
    body{font-family:system-ui,-apple-system,sans-serif;background:#f1f5f9;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
    
    .bt-card{background:#fff;border-radius:18px;padding:22px;width:100%;max-width:420px;box-shadow:0 18px 44px rgba(15,23,42,.1)}
    .bt-head{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:14px}
    .bt-head h3{font-size:16px;font-weight:800;color:#0f172a}
    .bt-head p{font-size:12px;color:#94a3b8;margin-top:2px}
    .bt-total{text-align:right}
    .bt-total span{font-size:18px;font-weight:800;color:#0f172a;font-variant-numeric:tabular-nums}
    .bt-of{font-size:12px;font-weight:600;color:#94a3b8}
    
    .bt-overall{margin-bottom:18px}
    .bt-overall-track{height:9px;background:#f1f5f9;border-radius:999px;overflow:hidden}
    .bt-overall-fill{height:100%;width:0;border-radius:999px;background:linear-gradient(90deg,#6366f1,#8b5cf6);transition:width .6s cubic-bezier(.4,0,.2,1)}
    .bt-overall-fill.warn{background:linear-gradient(90deg,#f59e0b,#f97316)}
    .bt-overall-fill.over{background:linear-gradient(90deg,#ef4444,#dc2626)}
    .bt-overall-label{display:block;margin-top:6px;font-size:11.5px;font-weight:600;color:#64748b}
    
    .bt-cats{display:flex;flex-direction:column;gap:13px}
    .bt-cat-row{display:flex;flex-direction:column;gap:5px}
    .bt-cat-top{display:flex;align-items:center;justify-content:space-between;font-size:13px}
    .bt-cat-name{display:flex;align-items:center;gap:7px;font-weight:700;color:#1e293b}
    .bt-cat-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
    .bt-cat-amt{font-weight:700;color:#475569;font-variant-numeric:tabular-nums;font-size:12.5px}
    .bt-cat-amt .bt-cap{color:#cbd5e1;font-weight:600}
    .bt-cat-track{height:6px;background:#f1f5f9;border-radius:999px;overflow:hidden}
    .bt-cat-fill{height:100%;border-radius:999px;transition:width .6s cubic-bezier(.4,0,.2,1)}
    .bt-cat-row.over .bt-cat-amt{color:#dc2626}
  `]
})
export class BudgetTrackerCardComponent implements AfterViewInit {
  ngAfterViewInit(): void {
    var CATEGORIES = [
      { name: 'Groceries', color: '#22c55e', spent: 380, cap: 500 },
      { name: 'Dining out', color: '#f59e0b', spent: 265, cap: 250 },
      { name: 'Transport', color: '#6366f1', spent: 140, cap: 300 },
      { name: 'Entertainment', color: '#ec4899', spent: 95, cap: 150 },
      { name: 'Shopping', color: '#0ea5e9', spent: 410, cap: 400 },
    ];
    var BUDGET_CAP = 2400;
    
    function render() {
      var totalSpent = CATEGORIES.reduce(function (s, c) { return s + c.spent; }, 0);
      document.getElementById('btSpent').textContent = '$' + totalSpent.toLocaleString();
    
      var pct = Math.min(100, (totalSpent / BUDGET_CAP) * 100);
      var overallFill = document.getElementById('btOverallFill');
      overallFill.style.width = pct + '%';
      overallFill.className = 'bt-overall-fill' + (totalSpent > BUDGET_CAP ? ' over' : pct > 80 ? ' warn' : '');
    
      var remaining = BUDGET_CAP - totalSpent;
      document.getElementById('btOverallLabel').textContent = remaining >= 0
        ? '$' + remaining.toLocaleString() + ' left this month (' + Math.round(pct) + '% used)'
        : '$' + Math.abs(remaining).toLocaleString() + ' over budget';
    
      var cats = document.getElementById('btCats');
      cats.innerHTML = CATEGORIES.map(function (c) {
        var catPct = Math.min(100, (c.spent / c.cap) * 100);
        var over = c.spent > c.cap;
        return '<div class="bt-cat-row' + (over ? ' over' : '') + '">' +
          '<div class="bt-cat-top">' +
            '<span class="bt-cat-name"><i class="bt-cat-dot" style="background:' + c.color + '"></i>' + c.name + '</span>' +
            '<span class="bt-cat-amt">$' + c.spent + ' <span class="bt-cap">/ $' + c.cap + '</span></span>' +
          '</div>' +
          '<div class="bt-cat-track"><div class="bt-cat-fill" style="width:' + catPct + '%;background:' + (over ? '#ef4444' : c.color) + '"></div></div>' +
        '</div>';
      }).join('');
    }
    
    render();

    // Bind inline-handler functions to the component so the template can call them
    Object.assign(this, { render });
  }
}