DEV Community

Cover image for Building a Distraction-Free, Formula-Transparent Concrete Calculator with Next.js & Tailwind
G S
G S

Posted on

Building a Distraction-Free, Formula-Transparent Concrete Calculator with Next.js & Tailwind

"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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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"]),
  };
}
Enter fullscreen mode Exit fullscreen mode

Two decisions worth flagging:

  • Discriminated union over a flat props bag. ConcreteInput forces TypeScript to narrow correctly inside rawCubicFeet — you physically cannot access diameterInches while handling the "slab" case. The compiler enforces the geometry, not a runtime if chain.

- 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 schemasBreadcrumbList, 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."
      }
    }
    // ...
  ]
};
Enter fullscreen mode Exit fullscreen mode

Two things matter here architecturally:

  1. 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.
  2. 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:

Concrete Yardage Calculator →

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:

  1. 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?
  2. 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.
  3. What's the worst unit-conversion bug you've personally shipped — feet vs. meters, cents vs. dollars, anything where a missing / 12 or / 100 caused real damage downstream?

Top comments (0)