The Last Straw
I needed to calculate stair stringer lengths for a deck project. Simple math. Pythagorean theorem, some IRC code checks, maybe a diagram.
Instead, I got this:
- 17 banner ads, three of which auto-played video with sound
- A "Continue to Calculator" button that scrolled me past 2,400 words of SEO filler
-
4 seconds of layout shift while ad slots fought for space around the one
<input>I actually needed - A 2.1MB JavaScript bundle for a tool that, mathematically, needs about 40 lines of code
- A results panel that popped up under a sticky ad banner, so I had to scroll to see my own numbers
I closed the tab, opened VS Code, and did the geometry by hand faster than the site would have let me.
Then I got annoyed enough to fix it properly. That annoyance became HypeCalc — a suite of construction and engineering calculators built the way tools should be built in 2026: no ads, no tracking scripts, no popups, just fast, correct, typed calculations rendered instantly in the browser.
This post is the engineering breakdown of the Stair Calculator specifically — the actual stair geometry math, the IRC code-compliance logic, and the Next.js/TypeScript architecture that makes it recalculate in under a millisecond on every keystroke.
If you've ever been mad enough at a bloated utility site to rebuild it yourself, this one's for you.
The Actual Problem: Stair Geometry Isn't Hard, It's Just Unforgiving
Before touching React, I had to get the math airtight, because stair geometry has zero tolerance for "close enough." A stringer cut a quarter-inch off spec is a tripping hazard, and most jurisdictions enforce the International Residential Code (IRC) for exactly that reason.
Here's the full derivation, in the order a carpenter actually thinks about it:
1. Total Rise vs. Target Unit Rise
Total Rise is the vertical distance you need to climb (floor to floor). You don't just pick a riser height and hope it divides evenly — you start from an ergonomic target, because comfortable stairs cluster around a 7.5" unit rise. Too short and the stair feels shallow and awkward; too tall and it's exhausting and, past a point, illegal.
targetUnitRise = 7.5 // inches, the sweet spot most codes gravitate toward
2. Solving for the Riser Count
You can't have a fractional stair, so you round to the nearest whole number of risers:
numberOfRisers = round(totalRise / targetUnitRise)
3. Actual Unit Rise (the number that matters)
This is the riser height you'll actually build, once the total rise is split evenly across whole risers:
actualUnitRise = totalRise / numberOfRisers
This is the step where most hand-built spreadsheets quietly go wrong — people forget to re-derive the actual rise and just use the 7.5" target, which throws off every downstream number.
4. Tread Count
Fencepost problem: a staircase with 14 risers has 13 treads, because the top riser lands you on the upper floor itself, not on a tread.
treadCount = numberOfRisers - 1
5. Total Run
Multiply tread count by tread depth (commonly 10"–11" per IRC minimums) to get the horizontal footprint:
totalRun = treadCount × treadDepth
6. Stringer Length (Pythagoras earns its keep)
The stringer is the diagonal structural member the treads sit on — literally the hypotenuse of the rise/run triangle:
stringerLength = √(totalRise² + totalRun²)
7. Incline Angle
Useful for headroom checks and for knowing whether you're building a staircase or a ladder:
inclineAngleDegrees = arctan(totalRise / totalRun) × (180 / π)
8. Code Compliance Check (IRC baseline)
- Riser height must fall between 4" and 7.75"
- Tread depth must be ≥ 10"
Anything outside that range gets flagged in the UI immediately — no silent bad math.
The TypeScript Core: Math Isolated From UI
The single biggest architectural decision was refusing to let calculation logic leak into components. The math is pure, synchronous, and 100% unit-testable with zero React in sight:
// lib/calculateStairs.ts
export interface StairInput {
totalRiseInches: number;
treadDepthInches: number;
targetUnitRiseInches?: number; // defaults to 7.5"
}
export interface StairResult {
numberOfRisers: number;
treadCount: number;
actualUnitRiseInches: number;
totalRunInches: number;
stringerLengthInches: number;
inclineAngleDegrees: number;
isRiserCompliant: boolean;
isTreadCompliant: boolean;
isFullyCompliant: boolean;
}
const IRC_MIN_RISER = 4;
const IRC_MAX_RISER = 7.75;
const IRC_MIN_TREAD = 10;
export function calculateStairs({
totalRiseInches,
treadDepthInches,
targetUnitRiseInches = 7.5,
}: StairInput): StairResult {
if (totalRiseInches <= 0 || treadDepthInches <= 0) {
throw new Error("Total rise and tread depth must be positive numbers.");
}
const numberOfRisers = Math.max(
1,
Math.round(totalRiseInches / targetUnitRiseInches)
);
const actualUnitRiseInches = totalRiseInches / numberOfRisers;
const treadCount = Math.max(0, numberOfRisers - 1);
const totalRunInches = treadCount * treadDepthInches;
const stringerLengthInches = Math.sqrt(
totalRiseInches ** 2 + totalRunInches ** 2
);
const inclineAngleDegrees =
totalRunInches === 0
? 90
: Math.atan(totalRiseInches / totalRunInches) * (180 / Math.PI);
const isRiserCompliant =
actualUnitRiseInches >= IRC_MIN_RISER &&
actualUnitRiseInches <= IRC_MAX_RISER;
const isTreadCompliant = treadDepthInches >= IRC_MIN_TREAD;
return {
numberOfRisers,
treadCount,
actualUnitRiseInches: Number(actualUnitRiseInches.toFixed(3)),
totalRunInches: Number(totalRunInches.toFixed(3)),
stringerLengthInches: Number(stringerLengthInches.toFixed(3)),
inclineAngleDegrees: Number(inclineAngleDegrees.toFixed(2)),
isRiserCompliant,
isTreadCompliant,
isFullyCompliant: isRiserCompliant && isTreadCompliant,
};
}
Why isolate it like this?
- Testability — this function has no dependency on React, the DOM, or fetch. It's a pure function of numbers in, numbers out. I can throw it into Jest/Vitest with zero mocking.
- Reusability — the exact same function powers a future CLI tool and a PDF export feature, without touching a single component.
- Correctness under refactors — when the UI changes (and it will), the geometry can't silently break, because it's not entangled with render logic.
The Client Component: Zero-Lag Recalculation
The UI layer's only job is to collect input and render StairResult. No debouncing needed — the calculation is cheap enough that useMemo recomputes it synchronously on every keystroke with no perceptible lag, and no wasted recompute on unrelated re-renders.
// components/StairCalculator.tsx
"use client";
import { useMemo, useState } from "react";
import { calculateStairs, type StairResult } from "@/lib/calculateStairs";
export default function StairCalculator() {
const [totalRise, setTotalRise] = useState(108); // inches
const [treadDepth, setTreadDepth] = useState(10.5); // inches
const result: StairResult | null = useMemo(() => {
if (totalRise <= 0 || treadDepth <= 0) return null;
return calculateStairs({
totalRiseInches: totalRise,
treadDepthInches: treadDepth,
});
}, [totalRise, treadDepth]);
return (
<div className="mx-auto w-full max-w-xl rounded-2xl border border-zinc-800 bg-zinc-950 p-6 text-zinc-100 shadow-xl">
<h2 className="mb-6 text-xl font-semibold tracking-tight">
Stair Calculator
</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1 text-sm text-zinc-400">
Total Rise (inches)
<input
type="number"
value={totalRise}
onChange={(e) => setTotalRise(Number(e.target.value))}
className="rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-2 text-zinc-100 outline-none focus:border-emerald-500"
/>
</label>
<label className="flex flex-col gap-1 text-sm text-zinc-400">
Tread Depth (inches)
<input
type="number"
value={treadDepth}
onChange={(e) => setTreadDepth(Number(e.target.value))}
className="rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-2 text-zinc-100 outline-none focus:border-emerald-500"
/>
</label>
</div>
{result && (
<div className="mt-6 space-y-3 rounded-xl border border-zinc-800 bg-zinc-900/60 p-4">
<Row label="Number of Risers" value={result.numberOfRisers} />
<Row label="Tread Count" value={result.treadCount} />
<Row
label="Actual Unit Rise"
value={`${result.actualUnitRiseInches}"`}
/>
<Row label="Total Run" value={`${result.totalRunInches}"`} />
<Row
label="Stringer Length"
value={`${result.stringerLengthInches}"`}
/>
<Row
label="Incline Angle"
value={`${result.inclineAngleDegrees}°`}
/>
<div
className={`mt-3 rounded-lg px-3 py-2 text-sm font-medium ${
result.isFullyCompliant
? "bg-emerald-500/10 text-emerald-400"
: "bg-red-500/10 text-red-400"
}`}
>
{result.isFullyCompliant
? "✓ IRC code compliant"
: "⚠ Outside IRC riser/tread limits — adjust your inputs"}
</div>
</div>
)}
</div>
);
}
function Row({ label, value }: { label: string; value: string | number }) {
return (
<div className="flex items-center justify-between text-sm">
<span className="text-zinc-400">{label}</span>
<span className="font-mono text-zinc-100">{value}</span>
</div>
);
}
A few deliberate decisions worth calling out:
-
Controlled inputs, no form libraries. For two numeric fields, pulling in a form library is pure overhead.
useStateis the right tool here — resist the urge to reach for machinery you don't need. -
useMemoinstead of an effect. There's no async work and no side effect — this is a derived value, not a side effect, souseEffect+ a second state variable would be the wrong model entirely (and a common source of double-render bugs). - Compliance rendered as a UI state, not an alert. Users adjusting inputs live shouldn't get interrupted by a modal every time they drift outside code limits; a persistent, color-coded status is far less hostile to the "just let me experiment" workflow.
Why This Architecture Wins on Core Web Vitals
The legacy tools I was comparing against are mostly still running on server-rendered PHP with jQuery bolted on for interactivity, plus 6–10 third-party ad/tracking scripts fighting for the main thread. Here's what a from-scratch Next.js build gets you instead:
| Metric | Legacy Calculator Sites | HypeCalc |
|---|---|---|
| Cumulative Layout Shift (CLS) | 0.25–0.4+ (ad slots load late) | 0 (no injected ad DOM) |
| Time to Interactive | 3–6s (waiting on ad networks) | Near-instant |
| JS Bundle (page-relevant) | 1.5–2.5MB | Tens of KB |
| Lighthouse Performance | 40–65 | 100/100 |
| Third-party render-blocking scripts | 5–15 | 0 |
| Recalculation latency | Server round-trip or laggy jQuery reflow | Sub-millisecond, client-side |
The pattern is simple: every millisecond of latency in a legacy calculator is spent serving someone other than the user — ad networks, trackers, engagement-bait modals. Strip all of that out and what's left is just... fast software, doing the one thing it's supposed to do.
No CLS is possible when there's no ad DOM competing for layout space. No render-blocking third-party JS is possible when there's no third-party JS. This isn't a clever optimization — it's just what's left when you delete the business model that made the old tools slow in the first place.
See It Running in Production
All of the above — the typed calculation engine, the memoized client component, the compliance checks — is live right now, not a CodeSandbox demo:
Open dev tools, throw the Lighthouse audit at it, watch the network tab stay empty of tracking calls. That's the whole pitch.
Let's Argue About This
A few things I'm genuinely curious where the community lands on:
- Is "ad-free utility tool" actually a sustainable business model, or does it inevitably rot into the same bloat once monetization pressure hits? I'd love to be wrong about the second half.
-
Where's the line between "isolate your business logic from your UI" and over-engineering a two-input calculator? I isolated
calculateStairson principle — was that the right call at this scale, or premature abstraction? - What's the worst ad-bloated utility website you've had to use recently, and did it make you angry enough to consider rebuilding it yourself?
Drop your worst offenders in the comments — I'm half-tempted to build the "revenge calculator" for whichever one is most infuriating.