"How Many Yards of Concrete for a 10x10 Slab?"
That was the search query. It should have taken ten seconds to answer.
Instead I landed on a calculator site that:
- Rendered a fake 4.8-star rating widget at the top, sourced from nothing, reviewing nothing
- Buried the actual input fields below 1,800 words of AI-generated filler about "the history of concrete"
- Used cubic feet and cubic yards interchangeably in the same paragraph, with no unit labels on the output
- Rounded a bag count from 47.2 bags down to 45, silently, with no waste factor applied — which is exactly how people under-order and end up with a half-poured slab at 4pm on a Saturday with the concrete truck gone
- Loaded six ad networks before the single
<input type="number">finished hydrating Concrete math is unforgiving in a specific way most calculators ignore: you can't go back for more mid-pour. Cold joints form, strength drops at the seam, and the fix is demolition. A calculator that quietly rounds down or mixes up cubic feet and cubic yards isn't a minor UX flaw — it's the kind of bug that costs someone a truck-load surcharge or a cracked slab.
So I built the version I actually wanted: correct unit math, an explicit waste factor, real bag-yield tables, and support for the three shapes that cover 90% of DIY pours — slabs, cylindrical post holes, and stepped stairs. No fake ratings. No filler. Here's the engineering behind it.
Live: Concrete Yardage Calculator →
The Math: Three Shapes, One Unit System, Zero Ambiguity
The core insight that most broken calculators get wrong: every formula needs to resolve to cubic feet first, then convert to cubic yards last. Mixing inches, feet, and yards mid-calculation is where silent errors creep in. So the architecture forces a single conversion boundary.
1. Rectangular Slabs, Driveways, Sidewalks
depthFeet = depthInches / 12
cubicFeet = lengthFeet × widthFeet × depthFeet
cubicYards = cubicFeet / 27
Worked example — 10×10 slab, 4" thick:
10 × 10 × (4/12) = 33.33 cu ft → 33.33 / 27 = 1.23 cu yds
2. Cylindrical Post Holes & Sonotubes
This is where most calculators fail entirely — they treat post holes as if they were rectangular, which overstates volume by roughly 27% (the difference between a circle and its bounding square).
radiusFeet = (diameterInches / 12) / 2
cubicFeet = π × radiusFeet² × depthFeet
cubicYards = cubicFeet / 27
Worked example — 12" diameter hole, 3' deep:
π × 0.5² × 3 = 2.356 cu ft → 2.356 / 27 = 0.087 cu yds
3. Solid Concrete Steps (Stacked Volume)
Steps aren't a single rectangular volume — each step sits on top of the one below it, so the total volume is a triangular stack, not width × tread × riser × stepCount. The correct formula uses the sum of the first n integers to account for the compounding:
stackedRisers = steps × (steps + 1) / 2
cubicFeet = widthFeet × treadFeet × riserFeet × stackedRisers
cubicYards = cubicFeet / 27
Using flat multiplication here overstates concrete needs by nearly 2x on a 5-step run, because it assumes every step is a full standalone block instead of an incremental layer.
4. The Non-Negotiable: Waste Factor
Every result gets a 10% overage applied by default, adjustable by the user — because subgrade isn't perfectly flat, forms flex under hydraulic pressure, and spillage during discharge is real. A calculator that reports the theoretical minimum instead of the practical order quantity is answering the wrong question.
recommendedYards = cubicYards × (1 + wasteFactorPercent / 100)
The TypeScript Core
Same architectural rule as always: calculation logic never touches JSX. One pure module, three shape handlers, one shared unit boundary.
// lib/calculateConcrete.ts
export type ConcreteShape = "slab" | "cylinder" | "steps";
interface SlabInput {
shape: "slab";
lengthFeet: number;
widthFeet: number;
depthInches: number;
}
interface CylinderInput {
shape: "cylinder";
diameterInches: number;
depthFeet: number;
holeCount: number;
}
interface StepsInput {
shape: "steps";
widthFeet: number;
treadInches: number;
riserInches: number;
stepCount: number;
}
export type ConcreteInput = SlabInput | CylinderInput | StepsInput;
export interface ConcreteResult {
cubicFeet: number;
cubicYards: number;
recommendedYards: number; // with waste factor applied
bags80lb: number;
bags60lb: number;
bags40lb: number;
}
const CUFT_PER_CUYD = 27;
const BAG_YIELD_CUFT = { "80": 0.6, "60": 0.45, "40": 0.3 } as const;
function rawCubicFeet(input: ConcreteInput): number {
switch (input.shape) {
case "slab": {
const depthFeet = input.depthInches / 12;
return input.lengthFeet * input.widthFeet * depthFeet;
}
case "cylinder": {
const radiusFeet = input.diameterInches / 12 / 2;
const perHole = Math.PI * radiusFeet ** 2 * input.depthFeet;
return perHole * input.holeCount;
}
case "steps": {
const treadFeet = input.treadInches / 12;
const riserFeet = input.riserInches / 12;
const stackedRisers = (input.stepCount * (input.stepCount + 1)) / 2;
return input.widthFeet * treadFeet * riserFeet * stackedRisers;
}
}
}
export function calculateConcrete(
input: ConcreteInput,
wasteFactorPercent: number = 10
): ConcreteResult {
const cubicFeet = rawCubicFeet(input);
const cubicYards = cubicFeet / CUFT_PER_CUYD;
const recommendedYards = cubicYards * (1 + wasteFactorPercent / 100);
const recommendedCubicFeet = recommendedYards * CUFT_PER_CUYD;
return {
cubicFeet: Number(cubicFeet.toFixed(3)),
cubicYards: Number(cubicYards.toFixed(3)),
recommendedYards: Number(recommendedYards.toFixed(3)),
bags80lb: Math.ceil(recommendedCubicFeet / BAG_YIELD_CUFT["80"]),
bags60lb: Math.ceil(recommendedCubicFeet / BAG_YIELD_CUFT["60"]),
bags40lb: Math.ceil(recommendedCubicFeet / BAG_YIELD_CUFT["40"]),
};
}
Two decisions worth flagging:
-
Discriminated union over a flat
propsbag.ConcreteInputforces TypeScript to narrow correctly insiderawCubicFeet— you physically cannot accessdiameterIncheswhile handling the"slab"case. The compiler enforces the geometry, not a runtimeifchain.
- Bag counts always round up (Math.ceil), never down. This is the fix for the silent-rounding bug that started this whole project. You cannot buy 44.7 bags of concrete. Rounding down is how people run short mid-pour.
The Client Component
// components/CalculatorForm.tsx
"use client";
import { useMemo, useState } from "react";
import { calculateConcrete, type ConcreteShape } from "@/lib/calculateConcrete";
export default function CalculatorForm() {
const [shape, setShape] = useState<ConcreteShape>("slab");
const [length, setLength] = useState(10);
const [width, setWidth] = useState(10);
const [depthInches, setDepthInches] = useState(4);
const [wasteFactor, setWasteFactor] = useState(10);
const result = useMemo(() => {
if (shape !== "slab") return null; // cylinder/steps forms omitted for brevity
if (length <= 0 || width <= 0 || depthInches <= 0) return null;
return calculateConcrete(
{ shape: "slab", lengthFeet: length, widthFeet: width, depthInches },
wasteFactor
);
}, [shape, length, width, depthInches, wasteFactor]);
return (
<div className="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm">
<div className="grid grid-cols-2 gap-4">
<NumberField label="Length (ft)" value={length} onChange={setLength} />
<NumberField label="Width (ft)" value={width} onChange={setWidth} />
<NumberField
label="Depth (in)"
value={depthInches}
onChange={setDepthInches}
/>
<NumberField
label="Waste factor (%)"
value={wasteFactor}
onChange={setWasteFactor}
/>
</div>
{result && (
<div className="mt-6 space-y-2 rounded-xl bg-indigo-50 p-4 text-sm">
<Row label="Cubic yards (exact)" value={`${result.cubicYards} yd³`} />
<Row
label="Recommended order"
value={`${result.recommendedYards} yd³`}
emphasis
/>
<Row label="80 lb bags needed" value={`${result.bags80lb}`} />
<Row label="60 lb bags needed" value={`${result.bags60lb}`} />
</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 outline-none focus:border-indigo-500"
/>
</label>
);
}
function Row({
label,
value,
emphasis,
}: {
label: string;
value: string;
emphasis?: boolean;
}) {
return (
<div className="flex justify-between">
<span className="text-zinc-600">{label}</span>
<span className={emphasis ? "font-bold text-indigo-700" : "font-mono text-zinc-800"}>
{value}
</span>
</div>
);
}
useMemo again does all the heavy lifting — no debounce, no server round-trip, no loading spinner for a computation that resolves in microseconds. The moment depthInches changes, bags80lb recomputes, and the browser never breaks a sweat.
The SEO Layer Is Structural, Not an Afterthought
The production page.tsx for this calculator does something a lot of "build a calculator" tutorials skip entirely: it ships three JSON-LD schemas — BreadcrumbList, SoftwareApplication, and FAQPage — server-rendered alongside the content, not injected client-side after the fact.
const faqSchema = {
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "How many bags of concrete equal a yard?",
"acceptedAnswer": {
"@type": "Answer",
"text": "It takes 45 bags of 80 lb mix, 60 bags of 60 lb mix, or 90 bags of 40 lb mix to equal one full cubic yard."
}
}
// ...
]
};
Two things matter here architecturally:
- The FAQ schema is a 1:1 mirror of the on-page FAQ copy — not a separate SEO-only payload. Google penalizes structured data that doesn't match visible content, and duplicating the answer by hand invites drift between the two. Keeping them as a single source rendered twice (schema + JSX) is a discipline worth enforcing, not a nice-to-have.
-
No fake
AggregateRating. It's tempting to bolt on a fabricated 4.8-star schema because it visually dominates the SERP snippet — but it's exactly the kind of manufactured trust signal that made the original broken calculator untrustworthy in the first place. Left it out entirely. The layout itself splits into a 5-column SEO content pane and a 4-column sticky calculator pane, so the interactive tool stays in the viewport while someone scrolls through the formula breakdown — instead of the classic pattern of forcing users to scroll past an essay to reach the input fields.
Why This Loads Instantly Compared to the Competition
| Metric | Typical Concrete Calculator Sites | HypeCalc |
|---|---|---|
| Fake review/rating widgets | Common | None — removed on principle |
| Unit conversion errors | Frequent (ft/in/yd mixed) | Single conversion boundary, enforced by types |
| Bag count rounding | Rounds down silently | Always rounds up (Math.ceil) |
| JSON-LD structured data | Often missing or fabricated | Real, content-matched FAQ schema |
| Recalculation latency | Server round-trip or jQuery reflow | Sub-millisecond client-side useMemo |
| Ads before first input | 3–6 | 0 |
Try It, Then Tell Me What's Wrong With It
The calculator above — slab, cylinder, and stacked-step volume math, all included — is live:
Feed it a real driveway pour and check the bag math against what your local supplier quotes.
A few things I want the comments to fight about:
- Discriminated unions vs. a single flat interface with optional fields — for a calculator with three shapes, was the union overkill, or is "the compiler prevents invalid states" worth the extra boilerplate at this scale?
- Should structured data (JSON-LD) be treated as a testable contract — i.e. should CI fail if the FAQ schema and the visible FAQ copy drift out of sync? I don't currently enforce this and probably should.
-
What's the worst unit-conversion bug you've personally shipped — feet vs. meters, cents vs. dollars, anything where a missing
/ 12or/ 100caused real damage downstream?
Top comments (0)