DEV Community

padillar
padillar

Posted on

Extreme UI/UX — Through the Lens of a Calculator

"This certifies that *$10,000** deposited at 4.35% APY shall mature to $10,443.78."*

That's not the result box. That is the calculator.

I built a CD rate calculator at cdratecalc.com. Minimal feature set — maybe an afternoon of work.

This post isn't about features. It's about the design decisions behind them: which metaphor to commit to, which motion belongs where, and how to verify that the details are actually right. Every misstep and do-over comes with the code from that moment.


0. This Is the Second Version

What you're looking at now is v2. The gap from v1 is wide enough to call it a rewrite.

V1 was built around the idea of a "receipt" — the kind of slip a bank teller hands you. I called it Ledger. Cream paper, near-black ink, gold used sparingly, Cormorant Garamond for headings. A gold CD badge sat center-page with the title and result stacked beneath it. Clean. I liked it.

The color palette looked like this:

:root {
  --cream:   #FBF7F0;  /* cream paper */
  --surface: #FFFFFF;  /* white card */
  --ink:     #1C1C1C;  /* near-black body */
  --muted:   #7A7267;  /* warm gray */
  --gold:    #B8860B;  /* gold accent */
  --green:   #2D5A3F;  /* moss green */
  --red:     #A64B4B;  /* brick red */
  --font-display: 'Cormorant Garamond', Georgia, serif;
  --font-body:    'Newsreader', Georgia, serif;
  --font-mono:    'JetBrains Mono', monospace;
}
Enter fullscreen mode Exit fullscreen mode

The problem with V1 surfaced later. Swap the logo and this design could be anything — a café menu, a wedding site. The CD calculator worked fine. So would a recipe app. Nothing in it said "this is about certificates of deposit."

The deeper issue was the metaphor. A receipt is something you glance at and toss. A CD isn't — it locks down a sum of money, a rate, and a maturity date. It's a legally binding instrument. Framing an instrument as a receipt flipped the whole tone. I needed authority. Cream paper and humanist serifs deliver warmth. Too much warmth, honestly — it gets in the way of trust when someone's deciding where to park their savings.

So I rebuilt. Not to make it prettier — to give it a different identity. The page stopped being "a form about CDs" and became the certificate itself. Cream paper became cold paper white. Humanist serifs became Bodoni — an engraved banknote typeface. The receipt layout gave way to certificate elements: a guilloché rosette, a serial number, an embossed seal, a security thread. Every line of functionality stayed. Every line of calculation logic stayed. What got torn down and rebuilt was the skin, and the understanding underneath it.

Some things survived: the 4% opacity paper grain, the gold progress bar at the end of each term row, the brute-force reverse calculation. Those were right in V1 and stayed. Everything else changed.


1. Form → Table: What Was Wrong

Every major CD calculator — Bankrate, Calculator.net, NerdWallet — follows the same pattern. Inputs on the left, a results table on the right, white background, cards, rounded corners. V1 was no different. The issue isn't missing features. The interaction grammar is off.

A form treats a CD as a query: enter parameters, get a number, done. But when you walk into a bank and open a CD, the teller doesn't hand you a query result. They hand you a piece of paper.

So after the rebuild, the interaction grammar shifted from query to certificate. Change a number — it's like a teller making an entry on the instrument. The result appears — it's like a seal stamping it into effect. The two most important animations, the odometer and the stamp, both trace back to this.

Everything lives in a single index.html. No framework, no build tools.


2. The Rosette: Two Seconds to Signal "Money"

The moment the page opens, a guilloché rosette draws itself on the left, line by line, then rotates almost imperceptibly slow. It has one job: make the user register, within a couple of seconds, that this is about money.

I needed a visual symbol that activates trust fast. Three options: draw a generic ornament (nobody recognizes it — you'd have to explain it first), use abstract geometry (modern, but has nothing to do with money), or use guilloché — the anti-counterfeiting pattern found on RMB, USD, and certificates of deposit. The last one needs no explanation. The eye does the recognition for you.

Real banknote guilloché is engraved by hand on a rose engine. I used a harmonic equation to do something similar:

function rosettePaths(main, alt) {
  // A "wavy ring": radius fluctuates with a sine wave
  //   r(θ) = base + amp · sin(k·θ + phase)
  const wavy = (base, amp, k, phase) => {
    const N = 240; let path = '';
    for (let i = 0; i <= N; i++) {
      const t = (i / N) * Math.PI * 2;
      const r = base + amp * Math.sin(k * t + phase);
      path += (i ? 'L' : 'M')
        + (r * Math.cos(t)).toFixed(2) + ' '
        + (r * Math.sin(t)).toFixed(2);
    }
    return path + 'Z';
  };

  // 12 rings: amplitude, wave count, and phase all staggered. Green and gold alternate.
  let rings = '';
  for (let i = 0; i < 12; i++) {
    const base  = 8 + i * 3.4;         // each ring slightly larger than the last
    const amp   = 1.1 + (i % 3) * 0.7; // amplitude variation staggered
    const k     = 16 + i * 2;           // wave count increases → petal density varies
    const color = i % 4 === 3 ? alt : main; // every 4th ring in copper-gold
    rings += `<path d="${wavy(base, amp, k, i * 0.6)}"
      fill="none" stroke="${color}" stroke-width="0.42"/>`;
  }
  return rings;
}
Enter fullscreen mode Exit fullscreen mode

The SVG is generated at runtime — no PNG download. Sharp at any DPI, and it keeps the single-file structure and offline PWA intact. With 12 rings, each at a different amplitude, wave count, and phase, you get a density no hand-drawn ornament can match. Math can.

For the entrance, I skipped fade-in. Fade-in is the default entrance for every app. Line drawing felt more like printing — stroke by stroke, as if being engraved:

.rosette path {
  stroke-dasharray: 1;      /* with pathLength="1", the entire path normalizes to 1 */
  stroke-dashoffset: 1;     /* hidden completely at first */
  animation: drawIn 2.6s cubic-bezier(.4,0,.2,1) forwards;
}
@keyframes drawIn { to { stroke-dashoffset: 0; } } /* drawn inch by inch */
Enter fullscreen mode Exit fullscreen mode

pathLength="1" normalizes the whole path to 1. dashoffset from 1 to 0 traces it end to end. Each of the 12 rings gets a slightly staggered animation-delay, so they emerge layer by layer. Once drawn, it completes one rotation every 150s — slow enough that you can't really see it moving. Not sure it even needs to move, honestly. But without it, something feels missing.


3. Serial Number: Input Is More Than Parameters

The top-right corner of the certificate carries a copper-gold serial: № 0435-0010000. Unlike the static decorative text, this is derived live from your inputs: the first four digits encode the rate, the last seven encode the principal.

function updateSerial(PV, r) {
  const rate    = String(Math.round(r * 10000)).padStart(4, '0'); // 4.35% → 0435
  const deposit = String(Math.round(PV)).padStart(7, '0');        // 10000 → 0010000
  document.getElementById('serial-num').textContent = `№ ${rate}-${deposit}`;
}
Enter fullscreen mode Exit fullscreen mode

Four lines. Change a number and you don't just get a result — you generate a one-of-a-kind certificate identifier. Input stops being a query parameter and starts feeling more like the identity of this certificate. Real CDs and real banknotes have serial numbers. This one has the data encoded right into it.


4. The Certification Sentence + Odometer: Output Isn't a Key-Value Pair

The result doesn't read Result: $10,443.78. It's written as a full certification sentence:

This certifies that $10,000 deposited at 4.35% APY shall mature to $10,443.78

The three key numbers are the certificate's fill-in-the-blank fields, with inputs embedded right in the sentence. You fill the blanks; the certificate reads back a conclusion — not a table spitting out a value.

For displaying the maturity amount, I went a different direction. A count-up — numbers ticking from zero to the target — is a digital-era animation. It "counts." I wanted a mechanical-era animation, where digits "settle" into place. Bank receipts have amounts typed line by line, not jumping around on a screen. So I built an odometer — like a currency counter or an old mechanical mileage gauge, each digit rolling independently:

const Odo = {
  slots: [],

  // First run: build a "reel" for each digit position
  build(str) {
    this.slots = [...str].map(ch => {
      if (!/\d/.test(ch)) {
        // Dollar signs, commas, dots: static characters
        const el = document.createElement('span');
        el.textContent = ch;
        return { type: 'char', el };
      }
      // Digit position: a reel loaded with 0–9, wrapped in a window showing one row
      const win  = document.createElement('span');
      const reel = document.createElement('span');
      for (let d = 0; d <= 9; d++) {
        const s = document.createElement('span');
        s.textContent = d;
        reel.appendChild(s);
      }
      win.appendChild(reel);
      return { type: 'digit', reel };
    });
  },

  // Each subsequent update: roll each reel to its target digit
  set(str) {
    [...str].forEach((ch, i) => {
      const slot = this.slots[i];
      if (slot.type !== 'digit') return;
      // Roll to digit N = shift the entire reel up by N character heights
      slot.reel.style.transitionDelay = (i * 24) + 'ms'; // stagger each digit by 24ms
      slot.reel.style.transform = `translateY(-${ch}em)`;
    });
  }
};
Enter fullscreen mode Exit fullscreen mode

Two details push it toward "mechanical" rather than "digital." translateY(-N em) uses em, not px — the reel shift always equals the character height, so the animation stays correct regardless of font size changes. The 24ms stagger per digit mimics the cascading linkage of real odometer gears. Remove that stagger and the screen-number feel comes right back — at least to my eye. Though honestly, if someone's using this in a rush and interacting at high frequency, count-up might be the better choice. This calculator doesn't get that kind of traffic, so count-up's advantages don't really apply here.


5. The Seal: Physicality + Signal Discipline

The upper-right of the certificate has a red circular seal. It gets re-stamped every time the numbers settle. A red seal is easy to ruin — one PNG slapped on top of the paper and it's over. To keep it from looking like a sticker, I tried three things:

.seal {
  transform: rotate(-8deg);      /* hand-stamped seals are never straight */
  mix-blend-mode: multiply;      /* crucial: red ink × paper = ink actually in the paper */
  opacity: 0.92;
}
.seal svg { filter: url(#seal-rough); } /* feTurbulence filter for uneven ink texture */

@keyframes stamp {
  0%   { transform: rotate(-8deg) scale(1.55); opacity: 0; }    /* lifted */
  55%  { transform: rotate(-8deg) scale(0.95); opacity: 0.95; } /* pressed down */
  78%  { transform: rotate(-8deg) scale(1.04); }                /* bounce back */
  100% { transform: rotate(-8deg) scale(1);    opacity: 0.92; } /* settled */
}
Enter fullscreen mode Exit fullscreen mode
// Throttle: wait 380ms after input stops, so the seal doesn't fire mid-typing
let timer = null;
function stampSeal() {
  clearTimeout(timer);
  timer = setTimeout(() => {
    seal.classList.remove('stamped');
    void seal.offsetWidth;        // force reflow so animation can replay
    seal.classList.add('stamped');
  }, 380);
}
Enter fullscreen mode Exit fullscreen mode

mix-blend-mode: multiply makes the red ink multiply against the paper color: pure red on white, sinking deeper as the paper darkens. With or without this one line — that's the difference between ink pressed in and a sticker pasted on. The scale goes 1.55 → 0.95 → 1.04 → 1 — impact, slight depression, rebound, settle — in 0.42 seconds. rotate(-8deg) stays constant throughout. The tilt is intentional: nobody stamps straight. Crooked is what makes it real.

The seal red is the only event color on the entire page, used in exactly two places: the stamp, and the negative-value warning when principal is touched. On this page, red isn't an error color — it's the "effective" color. One red, used sparingly. When it shows up, it carries weight — at least on this page it does.


6. Microprint: The Seam Is Where Texture Leaks

Real banknotes have a line of microprint along the edge — text so small you need a magnifier to read it, an anti-counterfeiting feature. I added one at the top and bottom of the certificate, using a 340px SVG tile with repeat-x:

.microprint {
  height: 7px; opacity: 0.5;
  background: url("data:image/svg+xml,...CERTIFICATE OF DEPOSIT ✦ FDIC INSURED ✦ ...")
              repeat-x;
}
Enter fullscreen mode Exit fullscreen mode

There was a seam problem. If the text doesn't fill exactly 340px, the tile edges misalign — …INSURED ✦ butts into OCERTIFICATE…, or there's a gap. Nobody's magnifying a 7px decorative strip, but the moment you spot a garbled seam, your brain flags it: "this thing is stitched together."

One line fixed it:

<svg width="340" height="8">
  <text x="0" y="6.5" font-size="6.5"
        textLength="340" lengthAdjust="spacing">
    CERTIFICATE OF DEPOSIT ✦ LEDGER ✦ FDIC INSURED ✦ …
  </text>
</svg>
Enter fullscreen mode Exit fullscreen mode

textLength="340" forces the text to fill the width. The overflow gets distributed across letter spacing via lengthAdjust="spacing". Every tile's right edge is now a clean ending. The seam is gone.


7. Security Thread + Paper Grain: Realism Is Layered

Paper feel doesn't come from any single effect. Grain, security thread, watermark — none of them look like paper on their own. Stacked together, they do.

The grain uses SVG feTurbulence to generate tiled noise at 4% opacity:

body::after {
  content: ''; position: fixed; inset: 0; pointer-events: none;
  background-image: url("data:image/svg+xml,...
    <feTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4'/>...");
  background-size: 256px 256px;
  opacity: 0.04;   /* barely visible — just enough to feel like textured paper */
}
Enter fullscreen mode Exit fullscreen mode

No extra requests, everything inlined in CSS.

The security thread is a 20px-wide copper-gold strip. Every 9 seconds, a sheen sweeps from top to bottom:

.thread {
  position: absolute; top: 0; bottom: 0; left: 70%; width: 20px;
  background: linear-gradient(90deg, transparent,
              rgba(140,109,47,.18) 50%, transparent);
}
.thread::after {                 /* the sweeping sheen */
  content: ''; position: absolute; width: 100%; height: 110px;
  background: linear-gradient(180deg, transparent,
              rgba(245,242,233,.5), transparent);
  animation: sheen 9s linear infinite;
}
@keyframes sheen { from { top: -120px; } to { top: 110%; } }
Enter fullscreen mode Exit fullscreen mode

Once every 9 seconds, very low intensity. Hold a real banknote up to the light and you see the metallic thread. This does something similar — makes you feel like the page is lit from within.

The watermark is the simplest — a 300px giant italic letter at 4.5% opacity, submerged in the paper.

Three layers, a few dozen lines of CSS, zero images, zero requests. Each one on its own barely registers. Stack all three and the paper feel shifts. If you've done similar things, you might recognize the pattern — a single effect always feels like something's missing, but after layering a few on, it gets hard to tell which one is doing the work.


8. The System: Parts Held Together by Rules

Individual elements can look convincing on their own. Without rules holding them together, they scatter. Three sets of self-imposed constraints keep the entire page inside one certificate.

Color: Five Colors, No Cross-Assignment

:root {
  --paper:  #F5F2E9;  /* cold paper white — banknote paper is a cool cotton-linen, not warm cream */
  --green:  #143C2C;  /* banknote ink green — the dominant color, used in borders, back, footer */
  --ink:    #161A17;  /* engraving black — body text and borders */
  --red:    #A93226;  /* seal red — event color, only for the stamp and "principal touched" */
  --gold:   #8C6D2F;  /* copper-gold — serial number, decoration, sparing use */
}
Enter fullscreen mode Exit fullscreen mode

Cold paper white, not warm cream. Cold paper + ink green lands closer to what financial institutions actually use. The green goes on large surfaces — back, footer, certificate border — not just as an accent. The lead roles are paper × green. Red only enters during events. Gold stays decorative. Five colors, each with one fixed role. Cross-border usage removed.

Typography: Four Fonts, Four Jobs

Cinzel           → inscription header (CERTIFICATE OF DEPOSIT), wide tracking, all caps
Bodoni Moda      → headings and large numbers. The extreme thick/thin contrast of a Didone — that's the sound of money
Newsreader       → body text and italics, readability
Spline Sans Mono → digits, serial numbers, ledger — monospaced for vertical alignment
Enter fullscreen mode Exit fullscreen mode

The previous humanist serif, Cormorant Garamond, was too soft. Bodoni's contrast comes straight from the engraving tradition — the "THE UNITED STATES OF AMERICA" on real banknotes is Didone. One caveat: Bodoni's thin strokes can be hard to read on low-DPI screens. Choosing it was a bet that most users are on Retina. Four fonts, four roles — inscription, heading, body, numbering — mapped directly to the real divisions on a certificate.

Motion: Only Semantic, Never Decorative

I stripped every "this looks nice" animation, keeping only four that have a causal relationship:

Animation Meaning
Rosette drawing Being printed
Odometer roll Amount settled
Seal stamp Effective
Security thread sheen Paper under light

All of them respect prefers-reduced-motion, handled by a single constant:

const REDUCE_MOTION = matchMedia('(prefers-reduced-motion: reduce)').matches;

// Seal: skip entirely
function stampSeal() { if (REDUCE_MOTION) return; ... }

// Odometer: no transition, jump straight to target
slot.reel.style.transition = REDUCE_MOTION ? 'none' : '';
Enter fullscreen mode Exit fullscreen mode

Metaphor can't hold usability hostage. For people with reduced motion enabled, numbers land instantly, the seal just appears. Nothing is lost — it just doesn't perform.


9. Reverse Calculation: When the Formula Breaks, Sweep

Forward compound interest is one line of Math.pow. Reverse — given a target, find the term — works with a logarithm when there's no monthly contribution. Add PMT and the rate gets buried in a polynomial. Algebraically unsolvable.

The approach is blunt:

function calcFV(PV, r, n, m, PMT) {
  const a = Math.pow(1 + r / m, n * m);       // (1 + r/m)^(n·m)
  return PV * a + PMT * (a - 1) / (r / m);    // principal FV + annuity FV from monthly additions
}
Enter fullscreen mode Exit fullscreen mode

Start from year 0, step by 0.02 years (~1 week), compute the future value each time, stop when it crosses the target.

function yearsToReach(target, PV, r, m, PMT) {
  for (let i = 0; i < 3000; i++) {
    const years = i * 0.02;                    // step size 0.02 years ≈ 1 week
    const a  = Math.pow(1 + r / m, years * m);
    const fv = PV * a + PMT * (a - 1) / (r / m);
    if (fv >= target) return years;            // first time crossing the target — that's the answer
  }
}
Enter fullscreen mode Exit fullscreen mode

A couple thousand iterations, a few dozen milliseconds. Precision is "to the week" — about right for a multi-year savings planner. Any finer wouldn't matter for this use case. If I were building a daily settlement tool, I'd need Newton's method instead. This approach isn't universal.

Same logic for the rate, with a step of 0.002. There's also a product judgment tucked in here: not every computed answer should be shown to the user.

// When the required rate is absurd, warn the user
if (rNeeded > 0.08) {
  warn('⚠ CD rates rarely exceed 6%. Consider higher-yield products.');
}
Enter fullscreen mode Exit fullscreen mode

Someone types in "turn $10k into $200k in 5 years" — the tool doesn't coldly return a fake 40% answer. It tells them that goal isn't realistic for a CD. At least for a calculator in this category, helping users spot unreasonable inputs matters more than being an interface that always says yes.


10. Data Visualization: Let the Chart Grow Out of the Data

No charting library. Each term row has a thin bar embedded next to the maturity amount, its width proportional to that row's future value relative to the maximum. A glance tells you which term pays best — no jumping out to a separate chart:

const maxFv = Math.max(1, ...rows.map(r => r.fv));
const barPct = fv => Math.max(4, Math.min(100, fv / maxFv * 100));
//                                     ↑ minimum 4%, so tiny values remain visible

// In the template:
const cell = `<td>
  <span>$${fmt(fv)}</span>
  <span class="row-bar"><i style="width:${barPct(fv)}%"></i></span>
</td>`;
Enter fullscreen mode Exit fullscreen mode
.row-bar    { height: 4px; background: var(--line); }
.row-bar i  { display: block; height: 100%;
              background: linear-gradient(90deg, var(--gold-light), var(--gold)); }
Enter fullscreen mode Exit fullscreen mode

Two lines of CSS, five of JS. These bars aren't a chart library substitute — they sit on the same row as the data, like a texture the data itself grew.


11. Turning Taste Into a Checklist

Design often gets described as subjective — a matter of taste. That's not the path I took across these five rounds of iteration.

I wrote a skeptic frontend evaluator: default to finding flaws, never compliments. Every score requires hard evidence. I wired it through Playwright — actually operating the page: changing inputs, tabbing through modes, triggering every reverse-calculation variant, testing three viewports, hitting real keys for keyboard navigation. Each evaluation report calculated contrast ratios pixel by pixel and scanned for overflow element by element.

The scoring has a hard cutoff: functionality below 7 kills the round. Round 2 crashed on exactly this — the desktop layout introduced horizontal scrolling on mobile, giving it a functionality score of 6. The whole round was scrapped. Looking good couldn't save it.

I also locked down 14 functional contracts: the oracle value ($10,000 @ 4.35% monthly compounding for 1 year = $10,443.78), compounding frequency monotonicity, the three reverse modes, early withdrawal penalty, post-tax, no mobile overflow… Every round had to pass every contract. That's what made a 2,281-line refactor feel safe — only the skin changed. The bones were held in place by contracts. The evaluator re-tested all of them each round. Zero regressions.

Don't hand over what can be verified to gut feeling.

Take the footer contrast. First measurement came back at 4.50:1 — WCAG AA requires exactly 4.5:1. Squeaking by. One color value:

/* before */ --muted: #66695E;  /* contrast 4.50:1 — scraping by */
/* after  */ --muted: #61645A;  /* contrast 5.39:1 — breathing room */
Enter fullscreen mode Exit fullscreen mode

Put 4.50 and 5.39 side by side: one hugs the pass line, the other has space. Whether passing and having margin are the same thing — to me they're not.

Or the reverse-calculation tabs. Originally mouse-only. Round 5 added a WAI-ARIA tabs keyboard model:

tablist.addEventListener('keydown', e => {
  const tabs = [...tablist.children];
  let i = tabs.indexOf(document.activeElement);
  if (e.key === 'ArrowRight') i = (i + 1) % tabs.length;
  else if (e.key === 'ArrowLeft') i = (i - 1 + tabs.length) % tabs.length;
  else if (e.key === 'Home') i = 0;
  else if (e.key === 'End') i = tabs.length - 1;
  else return;
  activateTab(tabs[i]);
});
tabs.forEach((t, j) => t.tabIndex = j === active ? 0 : -1);
Enter fullscreen mode Exit fullscreen mode

Looks can be great, but if the keyboard path is broken, it's not done — that one matters more to me than color or motion.

Does the contrast have breathing room? Can the keyboard navigate through? Is there horizontal overflow? Are decorative elements clipped? Every one of these can be answered with data. I didn't do this because of some special talent — I just spent the time going through each verifiable detail. Anyone willing to put in that time could do the same.

Ever done something like this — taken a gut feeling and broken it down into things you can check off, one by one?

The whole project is a single index.html. No framework, no build tools. The features and content won't change often, so abstractions built for "change" are just overhead here. The complexity I saved got spent on a cropped line of watermark text, a 340px tile seam, a contrast ratio that barely cleared the bar — tiny things someone else might not find worth the trouble.

Top comments (1)

Collapse
 
xulingfeng profile image
xulingfeng

Your article reads like one thing: clean. The good kind.
What got me — every design choice here has actual code backing it up. The mix-blend-mode on the seal, the 24ms stagger on the odometer, the textLength="340" microprint fix. These aren't "I think this looks nice" decisions. You measured them, or you tested them, or you found the seam the hard way and fixed it. The Playwright evaluator with 14 contracts baked into your own workflow — most people talk about that kind of discipline. You just did it.
Page looks great. But what makes it stick is the system underneath.