The $80 Mistake That Started This
I was pouring a river table. Live edge walnut slabs, a resin "river" down the middle, the whole Pinterest-core woodworking project.
I estimated the resin volume by eyeballing it against a five-gallon bucket. I was wrong by almost 40%. Deep pour epoxy isn't cheap — I'm talking $70–$90 per gallon for a real slow-cure formula — and I either had to eat the cost of a wasted batch or scramble to mix a second one mid-pour, which risks a visible seam line in the cured resin forever.
So I went looking for a calculator. What I found was the now-familiar genre of construction-utility site:
- A giant hero image of a finished river table, because SEO wants "engagement," not math
- 1,400 words of blog filler before the actual input fields
- A "mix ratio calculator" that only supported one brand's proprietary ratio
- No wood displacement handling at all — just raw mold volume, which overestimates resin need by 30–70% depending on your slab coverage
- Ads stacked so aggressively the input fields shifted position while I was mid-typing I closed the tab and did what I did with the Stair Calculator a few months back: built the thing properly, myself, with real volumetric chemistry and zero ad noise.
This is the breakdown of the Deep Pour Epoxy Resin Calculator — the actual displacement math, the mix-ratio logic, and the Next.js architecture behind it.
The Problem Legacy Calculators Get Wrong: Wood Isn't Water
Most epoxy calculators treat your mold like an empty box: Length × Width × Depth = resin needed. That's correct for a bare silicone mold. It's badly wrong for a river table, because a huge percentage of that "volume" is actually solid wood slab, not liquid resin.
Skip that correction and you'll either massively overbuy resin, or — worse — underestimate and run out mid-pour on a piece you can't easily top off without a visible cure line.
Here's the full derivation, the way a woodworker actually reasons through it:
1. Gross Mold Volume
Start with the raw container dimensions in inches:
grossVolume = length × width × pourDepth
For a circular mold (a lot of coasters and clock blanks use these):
grossVolume = π × (diameter / 2)² × pourDepth
2. Wood Displacement Subtraction
This is the step every basic calculator skips. River tables have wood slabs occupying a real percentage of the mold — commonly 30–75% depending on how "open" the river channel is:
netVolume = grossVolume × (1 - woodDisplacementPercent / 100)
Skip this and you've computed the volume of an empty mold, not the volume of resin you actually need.
3. The Safety Buffer
Cup residue, mold leveling error, and slab-grain absorption all eat into your poured volume. A 5% buffer is the industry-standard hedge:
bufferedVolume = netVolume × 1.05
4. Unit Conversion
Cubic inches aren't how anyone buys resin. Convert to the units on the bottle:
gallons = bufferedVolume / 231
fluidOz = bufferedVolume / 1.80469
liters = bufferedVolume / 61.0237
5. Mix Ratio Split
Deep pour epoxy is almost always 2:1 (resin:hardener) by volume — very different from the 1:1 ratio used for thin tabletop coats:
partA (resin) = bufferedVolume × (2 / 3)
partB (hardener) = bufferedVolume × (1 / 3)
Get this ratio wrong and the epoxy either stays permanently tacky or cures cloudy — there's no "close enough" with a two-part chemical reaction.
The TypeScript Core: Same Discipline as the Stair Calculator
Just like the stringer-length math in the stair tool, this logic has zero business living inside a component. It's pure, deterministic, and testable in isolation:
// lib/calculateEpoxyResin.ts
export type MoldShape = "rectangular" | "circular";
export type MixRatio = "2:1" | "1:1";
export interface EpoxyInput {
shape: MoldShape;
lengthInches?: number; // required for rectangular
widthInches?: number; // required for rectangular
diameterInches?: number; // required for circular
pourDepthInches: number;
woodDisplacementPercent?: number; // 0 for empty molds, e.g. coasters
mixRatio?: MixRatio; // defaults to 2:1 (deep pour standard)
safetyBufferPercent?: number; // defaults to 5
}
export interface EpoxyResult {
grossVolumeCubicInches: number;
netVolumeCubicInches: number;
bufferedVolumeCubicInches: number;
gallons: number;
fluidOunces: number;
liters: number;
partAOunces: number; // resin
partBOunces: number; // hardener
isSafeSinglePourDepth: boolean; // true if depth <= 4"
}
const CUBIC_INCHES_PER_GALLON = 231;
const CUBIC_INCHES_PER_FLUID_OZ = 1.80469;
const CUBIC_INCHES_PER_LITER = 61.0237;
const MAX_SAFE_SINGLE_POUR_INCHES = 4;
export function calculateEpoxyResin({
shape,
lengthInches,
widthInches,
diameterInches,
pourDepthInches,
woodDisplacementPercent = 0,
mixRatio = "2:1",
safetyBufferPercent = 5,
}: EpoxyInput): EpoxyResult {
if (pourDepthInches <= 0) {
throw new Error("Pour depth must be a positive number.");
}
const grossVolumeCubicInches =
shape === "circular"
? Math.PI * (diameterInches! / 2) ** 2 * pourDepthInches
: lengthInches! * widthInches! * pourDepthInches;
if (!Number.isFinite(grossVolumeCubicInches) || grossVolumeCubicInches <= 0) {
throw new Error("Invalid mold dimensions.");
}
const netVolumeCubicInches =
grossVolumeCubicInches * (1 - woodDisplacementPercent / 100);
const bufferedVolumeCubicInches =
netVolumeCubicInches * (1 + safetyBufferPercent / 100);
const fluidOunces = bufferedVolumeCubicInches / CUBIC_INCHES_PER_FLUID_OZ;
const [partRatio, hardenerRatio] =
mixRatio === "2:1" ? [2, 1] : [1, 1];
const totalParts = partRatio + hardenerRatio;
return {
grossVolumeCubicInches: Number(grossVolumeCubicInches.toFixed(2)),
netVolumeCubicInches: Number(netVolumeCubicInches.toFixed(2)),
bufferedVolumeCubicInches: Number(bufferedVolumeCubicInches.toFixed(2)),
gallons: Number((bufferedVolumeCubicInches / CUBIC_INCHES_PER_GALLON).toFixed(3)),
fluidOunces: Number(fluidOunces.toFixed(1)),
liters: Number((bufferedVolumeCubicInches / CUBIC_INCHES_PER_LITER).toFixed(3)),
partAOunces: Number(((fluidOunces * partRatio) / totalParts).toFixed(1)),
partBOunces: Number(((fluidOunces * hardenerRatio) / totalParts).toFixed(1)),
isSafeSinglePourDepth: pourDepthInches <= MAX_SAFE_SINGLE_POUR_INCHES,
};
}
Same architectural bet as before: the moment a calculation has real-world stakes — money, safety, wasted material — it earns its own pure function, decoupled from render cycles, decoupled from framework churn, unit-testable without mocking a single DOM node.
The Client Component: Instant Feedback, No Debounce Needed
Woodworkers adjusting slab displacement percentage or pour depth need to see the gallon count update as they type — this is a "how much do I need to buy" decision, and hesitation in the UI reads as untrustworthy math.
// components/CalculatorForm.tsx
"use client";
import { useMemo, useState } from "react";
import {
calculateEpoxyResin,
type EpoxyResult,
type MixRatio,
} from "@/lib/calculateEpoxyResin";
export default function CalculatorForm() {
const [length, setLength] = useState(60);
const [width, setWidth] = useState(30);
const [pourDepth, setPourDepth] = useState(2);
const [woodDisplacement, setWoodDisplacement] = useState(40);
const [mixRatio, setMixRatio] = useState<MixRatio>("2:1");
const result: EpoxyResult | null = useMemo(() => {
try {
return calculateEpoxyResin({
shape: "rectangular",
lengthInches: length,
widthInches: width,
pourDepthInches: pourDepth,
woodDisplacementPercent: woodDisplacement,
mixRatio,
});
} catch {
return null;
}
}, [length, width, pourDepth, woodDisplacement, mixRatio]);
return (
<div className="w-full rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm">
<div className="grid grid-cols-2 gap-4">
<NumberField label="Mold Length (in)" value={length} onChange={setLength} />
<NumberField label="Mold Width (in)" value={width} onChange={setWidth} />
<NumberField label="Pour Depth (in)" value={pourDepth} onChange={setPourDepth} />
<NumberField
label="Wood Displacement (%)"
value={woodDisplacement}
onChange={setWoodDisplacement}
/>
</div>
<div className="mt-4">
<label className="mb-1 block text-sm font-medium text-zinc-600">
Mix Ratio
</label>
<select
value={mixRatio}
onChange={(e) => setMixRatio(e.target.value as MixRatio)}
className="w-full rounded-lg border border-zinc-300 px-3 py-2 text-zinc-900"
>
<option value="2:1">2:1 — Deep Pour (standard)</option>
<option value="1:1">1:1 — Tabletop / Coating</option>
</select>
</div>
{result && (
<div className="mt-6 space-y-2 rounded-xl bg-indigo-50 p-4">
<Row label="Resin Needed" value={`${result.gallons} gal (${result.fluidOunces} oz)`} />
<Row label="Part A (Resin)" value={`${result.partAOunces} oz`} />
<Row label="Part B (Hardener)" value={`${result.partBOunces} oz`} />
<div
className={`mt-2 rounded-lg px-3 py-2 text-sm font-medium ${
result.isSafeSinglePourDepth
? "bg-emerald-100 text-emerald-700"
: "bg-red-100 text-red-700"
}`}
>
{result.isSafeSinglePourDepth
? "✓ Safe for a single deep pour"
: "⚠ Exceeds 4\" — split into multiple pours"}
</div>
</div>
)}
</div>
);
}
function NumberField({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (v: number) => void;
}) {
return (
<label className="flex flex-col gap-1 text-sm text-zinc-600">
{label}
<input
type="number"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="rounded-lg border border-zinc-300 px-3 py-2 text-zinc-900"
/>
</label>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between text-sm">
<span className="text-zinc-500">{label}</span>
<span className="font-mono font-semibold text-zinc-900">{value}</span>
</div>
);
}
Two decisions worth flagging for anyone building similar tools:
-
try/catchinsideuseMemo, not form validation gymnastics. Invalid geometry (zero or negative dimensions) throws inside the pure function, and the memo just returnsnull— no separate error-state machine needed for a two-input-shape calculator. - The compliance warning reuses the exact same pattern as the stair calculator's IRC check. Once you've built one "live safety threshold" indicator, it becomes a reusable mental model — and eventually a shared component — across every calculator in the suite.
The Part Most Case Studies Skip: SEO Architecture That Doesn't Cost Performance
The epoxy calculator page ships three JSON-LD schemas (Breadcrumb, SoftwareApplication, FAQPage) alongside a genuinely long-form content column — formulas, a worked example, a wood-displacement reference table, six FAQ entries mapped 1:1 into schema.
Normally, "SEO content + schema" is exactly what causes the layout bloat and CLS I complained about at the start of this post. The difference here is architectural, not aesthetic:
-
JSON-LD renders as inert
<script>tags, not DOM nodes — zero layout impact, zero render cost, pure crawler signal. - The calculator is sticky-positioned in its own column, so the SEO content scrolling underneath it never shifts the input fields the user is actively typing into.
- No content is client-fetched. The FAQ text, the worked example, the formula blocks — all of it is static JSX shipped at build time, not a skeleton-loader waiting on an API call. You can rank for "deep pour epoxy resin to hardener ratio calculator 2 to 1" and keep a 100/100 Lighthouse score — the two goals aren't actually in tension. The tension only shows up when SEO content gets bolted on via injected ad-network widgets instead of authored as first-party static markup.
Try It on a Real Project
Every formula above — displacement subtraction, mix-ratio splitting, the 4" single-pour safety check — is running live, not mocked:
HypeCalc Deep Pour Epoxy Resin Calculator →
Plug in your own river table dimensions. If the gallon number doesn't match what your resin brand's own site tells you, that's a bug report I genuinely want.
Let's Talk
- Where's the actual line between "helpful SEO content" and "content that exists purely to push the useful tool below the fold"? I tried to keep every paragraph on this page load-bearing — did I succeed, or is 1,400 words still too many before the input fields?
- Should utility calculators like this even carry JSON-LD/schema markup, or does optimizing for search discoverability quietly reintroduce the same incentives that bloated the sites we're all mad at?
- What's a calculation in your own field (woodworking, electrical, plumbing, whatever) that every online calculator gets subtly wrong the way generic epoxy calculators ignore wood displacement? I'm collecting ideas for the next tool in the suite.
Top comments (2)
Getting hit by layout shift right as you type a critical measurement is infuriating, especially when a wrong number costs actual money. Handling the wood displacement math purely client-side with React state means the user gets instant feedback on mix ratios as they adjust the slab coverage percentage. Stripping out the SEO bloat and keeping the interaction instantaneous completely transforms the utility of tools like this.
Thanks, Alexander! You hit on exactly why I built it.
That layout shift issue you mentioned (CLS) is my biggest pet peeve with online tools. HypeCalc is strictly no-ads, so keeping the core metrics locked into Next.js/React state was essential. I wanted to make sure that the displacement math (and percentages) updated instantly as the user types without causing jitter.
Really appreciate you calling out the client-side interaction speed—it transforms the DX (and craftsmanship experience) entirely.