"Just Order 10% Extra" Is Bad Advice, and Every Flooring Site Repeats It Anyway
I was quoting a herringbone tile job and did what everyone does: googled "how much extra tile do I need." Every result gave the same lazy, copy-pasted answer — "order 10% extra to be safe."
That number is fine for a straight grid. It is dangerously wrong for herringbone.
Here's why: in a standard running-bond layout, the offcut from the end of one row becomes the starter piece for the next row. Waste stays low — 8–10% covers you. But herringbone and chevron patterns meet every wall at a 45-degree angle, which means every single perimeter tile becomes a triangular cut that can't be reused anywhere else in the room. Grain direction, factory bevels, and pre-mitered chevron ends make most of that scrap permanently unusable. Order 10% extra on a herringbone floor and you will run out mid-install — and the replacement box you order next week almost never matches the dye lot of the first one.
So I built the calculator I actually needed: one that treats waste factor as a function of pattern complexity, not a flat number someone guessed once and every site since has copied.
This is the engineering breakdown of the Herringbone & Chevron Tile Waste Calculator — the real math behind pattern-specific waste factors, the carton-rounding logic that keeps you from short-ordering, and (because this one shipped as a real production page, not a toy) the programmatic SEO architecture — JSON-LD schemas, FAQ markup, breadcrumbs — that makes the page itself discoverable, not just functional.
The Math: Waste Factor Isn't a Constant, It's a Lookup
The core insight the "just add 10%" advice misses is that waste factor scales with how many perimeter collisions your pattern generates. I benchmarked this against real installer data and landed on a pattern-to-waste-factor table:
| Layout Pattern | Waste Factor | Why |
|---|---|---|
| Standard Grid / Running Bond | 8–10% | End cuts reused as next-row starters |
| 45° Herringbone | 15–20% | Perimeter diagonal cuts, non-reusable |
| Chevron (point-to-point) | 18–22% | Pre-mitered ends, directional alignment loss |
| Angled layout + obstacles | 20–25% | Drains, niches, pipe penetrations |
Once you know the waste factor, the actual formula is simple — the value was in not hardcoding a single number:
Total Square Feet Needed = (Room Length × Room Width) × (1 + Waste Factor)
Cartons to Order = ⌈ Total Square Feet Needed / Square Feet Per Carton ⌉
That ceiling function matters more than it looks. Never round down, and never round to the nearest whole number — round up, always. Tile dye lots vary between manufacturing runs, so a "just order one more box next week" plan often means a visibly mismatched batch showing up on your floor. The math needs to protect the user from that mistake by default, not just report a number and let them do the rounding themselves.
Worked example: a 120 sq ft bathroom floor, herringbone pattern (18% waste), 14.5 sq ft per carton:
120 × 1.18 = 141.6 sq ft required
141.6 / 14.5 = 9.76 → round up → 10 cartons (145 sq ft)
The TypeScript Core: Pattern-Aware, Not Hardcoded
Same architectural principle as every calculator I build: the math is a pure function, fully typed, with zero React or DOM dependency. This one's slightly more interesting than a plain formula because the waste factor itself is a lookup keyed on pattern complexity:
// lib/calculateTileWaste.ts
export type TilePattern =
| "grid"
| "herringbone"
| "chevron"
| "angled-complex";
export interface TileWasteInput {
roomLengthFt: number;
roomWidthFt: number;
sqFtPerCarton: number;
pattern: TilePattern;
/** Optional override if the user knows their exact waste allowance */
customWasteFactor?: number;
}
export interface TileWasteResult {
rawSquareFeet: number;
wasteFactor: number;
totalSquareFeetNeeded: number;
cartonsToOrder: number;
totalSquareFeetOrdered: number;
overageSquareFeet: number;
}
const PATTERN_WASTE_FACTORS: Record<TilePattern, number> = {
grid: 0.09,
herringbone: 0.18,
chevron: 0.2,
"angled-complex": 0.225,
};
export function calculateTileWaste({
roomLengthFt,
roomWidthFt,
sqFtPerCarton,
pattern,
customWasteFactor,
}: TileWasteInput): TileWasteResult {
if (roomLengthFt <= 0 || roomWidthFt <= 0 || sqFtPerCarton <= 0) {
throw new Error("Room dimensions and carton size must be positive.");
}
const wasteFactor = customWasteFactor ?? PATTERN_WASTE_FACTORS[pattern];
const rawSquareFeet = roomLengthFt * roomWidthFt;
const totalSquareFeetNeeded = rawSquareFeet * (1 + wasteFactor);
const cartonsToOrder = Math.ceil(totalSquareFeetNeeded / sqFtPerCarton);
const totalSquareFeetOrdered = cartonsToOrder * sqFtPerCarton;
return {
rawSquareFeet: Number(rawSquareFeet.toFixed(2)),
wasteFactor,
totalSquareFeetNeeded: Number(totalSquareFeetNeeded.toFixed(2)),
cartonsToOrder,
totalSquareFeetOrdered: Number(totalSquareFeetOrdered.toFixed(2)),
overageSquareFeet: Number(
(totalSquareFeetOrdered - rawSquareFeet).toFixed(2)
),
};
}
The customWasteFactor escape hatch matters — a pro installer who's measured their own scrap rate on past jobs shouldn't be locked into a lookup table designed for the general case. Default to good math, but never trap an expert user who knows better.
The Client Component: Live Recalculation, Ceiling Function Front and Center
// CalculatorForm.tsx
"use client";
import { useMemo, useState } from "react";
import {
calculateTileWaste,
type TilePattern,
type TileWasteResult,
} from "@/lib/calculateTileWaste";
const PATTERN_LABELS: Record<TilePattern, string> = {
grid: "Standard Grid",
herringbone: "45° Herringbone",
chevron: "Chevron",
"angled-complex": "Angled + Obstacles",
};
export default function CalculatorForm() {
const [roomLengthFt, setRoomLengthFt] = useState(12);
const [roomWidthFt, setRoomWidthFt] = useState(10);
const [sqFtPerCarton, setSqFtPerCarton] = useState(14.5);
const [pattern, setPattern] = useState<TilePattern>("herringbone");
const result: TileWasteResult | null = useMemo(() => {
if (roomLengthFt <= 0 || roomWidthFt <= 0 || sqFtPerCarton <= 0) {
return null;
}
return calculateTileWaste({
roomLengthFt,
roomWidthFt,
sqFtPerCarton,
pattern,
});
}, [roomLengthFt, roomWidthFt, sqFtPerCarton, pattern]);
return (
<div className="flex flex-col gap-4">
<label className="flex flex-col gap-1 text-sm font-medium text-zinc-700">
Room Length (ft)
<input
type="number"
value={roomLengthFt}
onChange={(e) => setRoomLengthFt(Number(e.target.value))}
className="rounded-lg border border-zinc-200 px-3 py-2 outline-none focus:border-indigo-500"
/>
</label>
<label className="flex flex-col gap-1 text-sm font-medium text-zinc-700">
Room Width (ft)
<input
type="number"
value={roomWidthFt}
onChange={(e) => setRoomWidthFt(Number(e.target.value))}
className="rounded-lg border border-zinc-200 px-3 py-2 outline-none focus:border-indigo-500"
/>
</label>
<label className="flex flex-col gap-1 text-sm font-medium text-zinc-700">
Carton Coverage (sq ft)
<input
type="number"
value={sqFtPerCarton}
onChange={(e) => setSqFtPerCarton(Number(e.target.value))}
className="rounded-lg border border-zinc-200 px-3 py-2 outline-none focus:border-indigo-500"
/>
</label>
<label className="flex flex-col gap-1 text-sm font-medium text-zinc-700">
Layout Pattern
<select
value={pattern}
onChange={(e) => setPattern(e.target.value as TilePattern)}
className="rounded-lg border border-zinc-200 px-3 py-2 outline-none focus:border-indigo-500"
>
{Object.entries(PATTERN_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
{result && (
<div className="mt-2 space-y-2 rounded-xl bg-indigo-50 p-4 text-sm">
<Row label="Waste Factor" value={`${(result.wasteFactor * 100).toFixed(0)}%`} />
<Row label="Raw Square Footage" value={`${result.rawSquareFeet} sq ft`} />
<Row label="Total Needed (with waste)" value={`${result.totalSquareFeetNeeded} sq ft`} />
<Row label="Cartons to Order" value={result.cartonsToOrder} />
<Row label="Total Ordered" value={`${result.totalSquareFeetOrdered} sq ft`} />
<Row label="Overage Buffer" value={`${result.overageSquareFeet} sq ft`} />
</div>
)}
</div>
);
}
function Row({ label, value }: { label: string; value: string | number }) {
return (
<div className="flex items-center justify-between">
<span className="text-zinc-600">{label}</span>
<span className="font-mono font-semibold text-zinc-900">{value}</span>
</div>
);
}
Switching the pattern dropdown instantly re-derives waste factor, total square footage, and carton count — all client-side, all synchronous, no loading spinner because there's nothing to wait on.
The Part Most "Just Ship the Component" Tutorials Skip: Making the Page Findable
A fast, correct calculator that nobody finds is a portfolio piece, not a tool. The production page pairs the calculation logic above with a programmatic SEO layer that most React tutorials never touch — three JSON-LD schemas injected directly into the page:
const faqSchema = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: [
{
"@type": "Question",
name: "How much waste should I add for herringbone tile?",
acceptedAnswer: {
"@type": "Answer",
text: "Plan for a 15% to 20% waste factor on standard rectangular rooms...",
},
},
// ...
],
};
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }}
/>
Three things worth calling out here, because they're easy to get wrong:
- The FAQ schema is a 1:1 mirror of the visible on-page FAQ content, word for word. Schema markup that doesn't match what's rendered on the page is a common way to get flagged by search engines for cloaking — the JSON-LD isn't a place to stuff extra keywords the user never sees.
-
The
SoftwareApplicationschema deliberately omits a fakeaggregateRating. It's tempting to bolt on a"ratingValue": "4.9"block because rich snippets with star ratings get higher CTR — but a rating with no actual review data behind it is fabricated structured data, and it's exactly the kind of thing that gets a listing penalized once caught. -
Breadcrumb schema is generated from the same
breadcrumbItemsarray that renders the visible breadcrumb UI — one source of truth, so the structured data can't silently drift out of sync with what's on the page after a future edit. This is the unglamorous half of building tools people can actually find: the calculation logic gets you a good tool, the schema layer gets you found by someone typing "how much extra tile for herringbone" into a search bar at 11pm.
Why This Beats the Legacy Flooring Calculator Sites
| Legacy Flooring Sites | This Build | |
|---|---|---|
| Waste factor | Flat "10% for everything" | Pattern-aware lookup, 8–25% |
| Rounding | Often silently truncates | Explicit ceiling function, always rounds up |
| Structured data | Missing, or fabricated ratings | Accurate JSON-LD, 1:1 with visible content |
| Recalculation | Full page reload or laggy jQuery | Instant client-side useMemo
|
| Third-party scripts | Ad networks, trackers, popups | Zero |
See the Full Page in Production
Every schema, every formula, every input above is live right now: Herringbone & Chevron Tile Waste Calculator →
View source, check the JSON-LD in the <head>, and run it through Lighthouse — the SEO score matters here just as much as the performance score.
Discussion
A few things I want the community's actual take on:
- Where's the ethical line on structured data? Mirroring visible FAQ content into schema feels legitimate to me — is there a point where "helping search engines understand your page" tips into manipulation?
- Should calculation defaults ever be opinionated (like a pattern-specific waste table), or should tools always demand the user supply every input themselves? I lean toward smart defaults with an override — where do you land?
- Have you shipped programmatic SEO into a React/Next.js app, and did it actually move organic traffic, or was it wasted effort compared to just writing good content? Tell me I'm wrong about the schema approach in the comments — I want to hear the counterargument.
Top comments (0)