I built a certificate of deposit calculator. The whole thing is one index.html. Drop it on a server and you're done: cdratecalc.com.
There isn't much to change about this page. The feature set is what it is: future value across different terms, working backwards from a target amount, early withdrawal penalties, after-tax returns. Once it was done, it was done. The only thing I might touch is the bank rate data.
Both the page content and the features are stable. I don't need a framework to manage complexity for me.
Frameworks help with change: component boundaries, state management, scoped styles. They keep your codebase sane when you're coming back to it next month. But if the page barely changes, those abstractions are pure overhead. The CSS is written and won't be rewritten. The state is a handful of input fields. The templates are a few table rows. I can keep track of this myself.
Managing complexity by hand
About 490 lines of CSS. No scoped styles, no BEM, no utility classes. Everything is global, organized by naming convention. It's one file. How bad could the collisions get?
About 460 lines of JS. State lives directly in the DOM: the input values are the single source of truth. The update loop reads values, crunches numbers, and dumps innerHTML into a table. No store, no virtual DOM, no unidirectional data flow.
In a project where requirements change, writing like this is asking for trouble. But here, the state is a few numbers flowing from inputs to a table. Throwing a framework at that is overkill.
Templates are string concatenation. You'll see html += ... in there. Ugly? Sure. But in a scope this small it's hard to get wrong, and it's fast.
The UI: finding the right feel
My first attempt was standard app UI: cards, rounded corners, drop shadows. Looked fine. But dropped into a money context, something felt off. Couldn't put my finger on it.
After a few iterations, staring at the screen one night, it clicked. The problem wasn't that it looked bad. It was that it looked wrong for the job. When someone opens a tool about money, their first reaction is suspicion, not admiration. The interface needs to communicate trust, not design chops.
So I pivoted toward printed materials. The look of a receipt from a bank teller. Colors pulled way back: cream background, near-black text, a few greys for hierarchy, gold used sparingly as an accent only. Three typefaces, each with a job: Cormorant Garamond for headings (a humanist serif), Newsreader for body text (also serif, reads comfortably), JetBrains Mono for numbers (monospaced, so dollar amounts align). The whole palette lives in CSS custom properties:
:root {
--cream: #FBF7F0;
--surface: #FFFFFF;
--ink: #1C1C1C;
--ink-soft: #3D3833;
--muted: #7A7267;
--gold: #B8860B;
--gold-bright: #D4A853;
--gold-pale: #FDF3D0;
--green: #2D5A3F;
--red: #A64B4B;
--border: #E7DFD2;
--border-light: #F0EAE0;
--font-display: 'Cormorant Garamond', 'Palatino', Georgia, serif;
--font-body: 'Newsreader', 'Palatino', Georgia, serif;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace;
}
If it's going to feel like paper, the background can't be flat white. I added an SVG feTurbulence filter as a noise texture, 4% opacity, tiled across the whole page:
body::after {
content: '';
position: fixed;
inset: -50%;
width: 200%;
height: 200%;
pointer-events: none;
background-image: url("data:image/svg+xml,...%3CfeTurbulence
type='fractalNoise' baseFrequency='0.75' numOctaves='4'/%3E...");
background-repeat: repeat;
background-size: 256px 256px;
opacity: 0.04;
}
You don't consciously notice it unless you look for it. It just reads as textured paper. No extra request, everything inlined in the CSS background.
Pixel-pushed to death. Every color value, every spacing value, every hover transition timing — dialed in by hand over multiple passes. CSS-in-JS generated class names don't give you that level of control. Want to tweak one spacing value? You've got layers of abstraction in the way.
The math: brute force when there's no formula
The compound interest formula itself is straightforward. Given principal, rate, term, and monthly contributions, computing the future value is one line of Math.pow.
Going the other direction is messier. Given a target future value, solve for years or solve for rate. Without monthly contributions, there are closed-form solutions. Add PMT into the mix and the clean formulas evaporate. The interest rate is buried inside a polynomial; you can't isolate it algebraically.
So you brute-force it. The browser doesn't need anything clever:
// With monthly contributions, solve for years to reach target
let nYears = 0;
for (let attempt = 0; attempt < 3000; attempt++) {
const guess = attempt * 0.02;
const a = Math.pow(1 + r / m, guess * m);
const fvGuess = PV * a + PMT * (a - 1) / (r / m);
if (fvGuess >= target) { nYears = guess; break; }
}
Pick a starting value, take a step, check if you've hit the target, keep going. A couple hundred iterations at most. Tens of milliseconds.
Penalties and taxes are computed the same way. Everything sync, everything in the browser.
Bar charts without a charting library
At the end of each term row, there's a small gold bar. Its width shows the ending balance relative to the largest one across all terms. No chart library. Two CSS rules and a few lines of JS:
.row-bar {
height: 4px;
background: var(--border-light);
border-radius: 999px;
overflow: hidden;
}
.row-bar i {
display: block;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--gold-light), var(--gold));
}
The JS side is even simpler: find the largest future value across all rows, use it as 100%, then each bar gets value / max * 100 as its width percentage. The width is computed while building the template string and dropped into an <i> element:
const maxFv = Math.max(1, ...rows.map(r => r.fv));
const barPct = (fv) =>
Math.max(4, Math.min(100, (fv / maxFv) * 100));
const bar =
`<div class="row-bar"><i style="width:${barPct(row.fv)}%"></i></div>`;
A few dozen lines total. Looks as good as a chart library would, and like the rest of this page, it won't need maintenance.
Small details that matter
After you run a calculation, the page shows two contextual prompts. One suggests checking early withdrawal penalties for longer terms. The other offers to work backwards from a savings target. They don't show up all the time: the penalty prompt only appears when the 10-year return exceeds 1.5x the principal. Short-term CDs earn so little there's nothing worth warning about.
In the penalty results, if the penalty exceeds the interest you've earned, the page says "dips into principal" rather than showing a red negative number. One line of grey text. No modal, nothing flashing. Like a footnote on a receipt. If you notice it, you notice it.
The count-up animation uses a custom easing curve, and it checks prefers-reduced-motion. If you have reduced motion enabled, the numbers jump to their final value instantly. No transition.
Top comments (1)
goods