Construction — Line-Item Quote Builder
A rugged trades estimate sheet that adds labor and material rows with editable description, quantity, unit and unit price, computing live line totals, a markup percent, contingency and tax, then a grand total with a send-quote action and dynamic add or remove rows.
MCP
Code
:root {
--hv: #f5c518;
--steel: #2b3440;
--steel-d: #1b222b;
--bg: #eceae6;
--ink: #1a1d22;
--muted: #666c75;
--orange: #e8642a;
--white: #ffffff;
--line: #cfccc5;
--line-2: #d9d6d0;
--green: #2f8f4e;
--radius: 6px;
--shadow: 0 2px 0 rgba(27, 34, 43, 0.18), 0 10px 28px rgba(27, 34, 43, 0.1);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
* {
box-sizing: border-box;
margin: 0;
}
body {
background:
repeating-linear-gradient(135deg, rgba(43, 52, 64, 0.025) 0 14px, transparent 14px 28px),
var(--bg);
color: var(--ink);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
line-height: 1.45;
min-height: 100vh;
padding: 28px 16px;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.mono {
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
.quote {
max-width: 880px;
margin: 0 auto;
background: var(--white);
border: 2px solid var(--steel);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
}
/* ---- header ---- */
.quote__head {
background: var(--steel);
color: var(--white);
padding: 22px 24px;
display: flex;
flex-wrap: wrap;
gap: 20px;
justify-content: space-between;
align-items: flex-start;
}
.quote__brand {
display: flex;
gap: 14px;
align-items: center;
}
.quote__hazard {
width: 10px;
align-self: stretch;
min-height: 48px;
border-radius: 2px;
background: repeating-linear-gradient(
-45deg,
var(--hv) 0 8px,
var(--steel-d) 8px 16px
);
}
.quote__co {
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--hv);
}
.quote__head h1 {
font-size: 1.7rem;
font-weight: 800;
letter-spacing: -0.02em;
text-transform: uppercase;
}
.quote__meta {
display: flex;
gap: 22px;
flex-wrap: wrap;
}
.quote__meta dt {
font-size: 0.62rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #9aa3ae;
}
.quote__meta dd {
font-size: 0.95rem;
font-weight: 700;
}
/* ---- cards ---- */
.card {
padding: 20px 24px 16px;
border-bottom: 1px solid var(--line);
}
.card__top {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.card__top h2 {
font-size: 1.15rem;
font-weight: 800;
letter-spacing: -0.01em;
text-transform: uppercase;
}
.badge {
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 3px 9px;
border-radius: 999px;
border: 1.5px solid currentColor;
}
.badge--labor {
color: var(--orange);
}
.badge--mat {
color: var(--steel);
}
/* ---- table ---- */
.table {
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
}
.row {
display: grid;
grid-template-columns: 1fr 64px 72px 92px 104px 36px;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid var(--line-2);
}
.row:last-child {
border-bottom: 0;
}
.row--head {
background: var(--bg);
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
.rows .row:nth-child(even) {
background: rgba(43, 52, 64, 0.02);
}
.col-total {
text-align: right;
font-weight: 700;
font-size: 0.92rem;
}
.col-rm {
text-align: center;
}
.f {
width: 100%;
font: inherit;
font-size: 0.88rem;
padding: 7px 8px;
border: 1px solid var(--line);
border-radius: 4px;
background: var(--white);
color: var(--ink);
}
.f-qty,
.f-price {
text-align: right;
}
.f:focus,
.pct input:focus {
outline: none;
border-color: var(--steel);
box-shadow: 0 0 0 3px rgba(43, 52, 64, 0.16);
}
.rm {
width: 28px;
height: 28px;
border: 1px solid var(--line);
background: var(--white);
color: var(--muted);
border-radius: 4px;
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
transition: all 0.12s ease;
}
.rm:hover {
border-color: var(--orange);
color: var(--white);
background: var(--orange);
}
.rm:focus-visible {
outline: 3px solid rgba(232, 100, 42, 0.4);
outline-offset: 1px;
}
.addrow {
margin-top: 12px;
font: inherit;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 9px 16px;
border: 2px dashed var(--steel);
border-radius: var(--radius);
background: transparent;
color: var(--steel);
cursor: pointer;
transition: all 0.12s ease;
}
.addrow:hover {
background: var(--steel);
color: var(--white);
}
.addrow:focus-visible {
outline: 3px solid rgba(43, 52, 64, 0.35);
outline-offset: 2px;
}
/* ---- summary ---- */
.summary {
padding: 22px 24px 26px;
background: var(--bg);
}
.summary__lines {
max-width: 380px;
margin-left: auto;
display: flex;
flex-direction: column;
gap: 4px;
}
.sline {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 7px 0;
font-size: 0.92rem;
}
.sline > span:first-child,
.sline label {
color: var(--muted);
font-weight: 600;
}
.sline--base {
border-top: 1px solid var(--line);
margin-top: 4px;
padding-top: 11px;
font-weight: 700;
}
.sline--base > span:first-child {
color: var(--ink);
}
.pct {
display: flex;
align-items: center;
gap: 10px;
}
.pct input {
width: 70px;
font: inherit;
font-size: 0.88rem;
text-align: right;
padding: 6px 8px;
border: 1px solid var(--line);
border-radius: 4px;
background: var(--white);
color: var(--ink);
}
.pct .mono {
min-width: 78px;
text-align: right;
font-weight: 700;
}
.grand {
max-width: 380px;
margin: 16px 0 16px auto;
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
padding: 14px 16px;
background: var(--steel);
color: var(--white);
border-radius: var(--radius);
border-left: 8px solid var(--hv);
}
.grand span {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.grand strong {
font-size: 1.65rem;
font-weight: 800;
}
.send {
display: block;
max-width: 380px;
width: 100%;
margin-left: auto;
font: inherit;
font-size: 0.9rem;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 14px;
border: 2px solid var(--steel-d);
border-radius: var(--radius);
background: var(--hv);
color: var(--steel-d);
cursor: pointer;
transition: transform 0.08s ease, box-shadow 0.12s ease;
box-shadow: 0 4px 0 var(--steel-d);
}
.send:hover {
transform: translateY(-1px);
box-shadow: 0 5px 0 var(--steel-d);
}
.send:active {
transform: translateY(2px);
box-shadow: 0 1px 0 var(--steel-d);
}
.send:focus-visible {
outline: 3px solid rgba(43, 52, 64, 0.5);
outline-offset: 3px;
}
.send.is-sent {
background: var(--green);
color: var(--white);
border-color: #226a3a;
box-shadow: 0 4px 0 #226a3a;
}
.send__note {
max-width: 380px;
margin: 10px 0 0 auto;
text-align: right;
font-size: 0.82rem;
font-weight: 600;
color: var(--green);
min-height: 1.1em;
}
/* ---- responsive ---- */
@media (max-width: 520px) {
body {
padding: 14px 8px;
}
.quote__head {
padding: 18px 16px;
}
.quote__head h1 {
font-size: 1.35rem;
}
.card,
.summary {
padding-left: 14px;
padding-right: 14px;
}
.row--head {
display: none;
}
.row {
grid-template-columns: 1fr 1fr;
gap: 8px 10px;
padding: 12px 10px;
position: relative;
}
.col-desc {
grid-column: 1 / -1;
}
.col-qty::before,
.col-unit::before,
.col-price::before {
content: attr(data-lbl);
display: block;
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 3px;
}
.col-total {
grid-column: 1 / -1;
text-align: left;
border-top: 1px dashed var(--line);
padding-top: 8px;
}
.col-total::before {
content: 'Line total: ';
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.col-rm {
position: absolute;
top: 10px;
right: 8px;
}
.summary__lines,
.grand,
.send,
.send__note {
max-width: none;
}
}(function () {
'use strict';
var USD = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
});
var tpl = document.getElementById('row-tpl');
var laborRows = document.getElementById('labor-rows');
var materialRows = document.getElementById('material-rows');
var seed = {
labor: [
{ desc: 'Site demolition & haul-off', qty: 24, unit: 'hr', price: 68 },
{ desc: 'Framing crew — interior walls', qty: 40, unit: 'hr', price: 72 },
{ desc: 'Licensed electrician — rough-in', qty: 16, unit: 'hr', price: 95 },
],
material: [
{ desc: '2x4 SPF studs (8 ft)', qty: 120, unit: 'ea', price: 4.85 },
{ desc: '1/2" drywall sheets (4x8)', qty: 38, unit: 'sheet', price: 14.2 },
{ desc: 'Romex 12/2 wiring', qty: 250, unit: 'ft', price: 0.92 },
],
};
function makeRow(kind, data) {
var node = tpl.content.firstElementChild.cloneNode(true);
var desc = node.querySelector('.f-desc');
var qty = node.querySelector('.f-qty');
var unit = node.querySelector('.f-unit');
var price = node.querySelector('.f-price');
desc.value = data.desc;
qty.value = data.qty;
unit.value = data.unit;
price.value = data.price;
// mobile column labels
node.querySelector('.col-qty').setAttribute('data-lbl', 'Qty');
node.querySelector('.col-unit').setAttribute('data-lbl', 'Unit');
node.querySelector('.col-price').setAttribute('data-lbl', 'Price');
node.dataset.kind = kind;
node.addEventListener('input', recalc);
node.querySelector('.rm').addEventListener('click', function () {
node.remove();
recalc();
});
return node;
}
function lineTotal(row) {
var q = parseFloat(row.querySelector('.f-qty').value) || 0;
var p = parseFloat(row.querySelector('.f-price').value) || 0;
var total = q * p;
row.querySelector('.lt').textContent = USD.format(total);
return total;
}
function sumGroup(container) {
var total = 0;
container.querySelectorAll('.row').forEach(function (row) {
total += lineTotal(row);
});
return total;
}
function pct(id) {
var v = parseFloat(document.getElementById(id).value);
return isNaN(v) || v < 0 ? 0 : v / 100;
}
function set(id, val) {
document.getElementById(id).textContent = USD.format(val);
}
function recalc() {
var labor = sumGroup(laborRows);
var mat = sumGroup(materialRows);
var base = labor + mat;
var markup = base * pct('markup');
var contingency = (base + markup) * pct('contingency');
var preTax = base + markup + contingency;
var tax = preTax * pct('tax');
var grand = preTax + tax;
set('sum-labor', labor);
set('sum-mat', mat);
set('sum-base', base);
set('sum-markup', markup);
set('sum-cont', contingency);
set('sum-tax', tax);
set('grand-total', grand);
resetSend();
}
// ---- add rows ----
document.querySelectorAll('[data-add]').forEach(function (btn) {
btn.addEventListener('click', function () {
var kind = btn.getAttribute('data-add');
var blank = { desc: '', qty: 1, unit: kind === 'labor' ? 'hr' : 'ea', price: 0 };
var target = kind === 'labor' ? laborRows : materialRows;
var row = makeRow(kind, blank);
target.appendChild(row);
row.querySelector('.f-desc').focus();
recalc();
});
});
// ---- live percentages ----
['markup', 'contingency', 'tax'].forEach(function (id) {
document.getElementById(id).addEventListener('input', recalc);
});
// ---- send quote ----
var sendBtn = document.getElementById('send-quote');
var note = document.getElementById('send-note');
var sent = false;
function resetSend() {
if (sent) {
sent = false;
sendBtn.classList.remove('is-sent');
sendBtn.textContent = 'Send quote';
note.textContent = '';
}
}
sendBtn.addEventListener('click', function () {
var grand = document.getElementById('grand-total').textContent;
var no = 'EST-' + (4800 + Math.floor(Math.random() * 200));
document.getElementById('quote-no').textContent = no;
sent = true;
sendBtn.classList.add('is-sent');
sendBtn.textContent = 'Quote sent ✓';
note.textContent = 'Quote ' + no + ' for ' + grand + ' sent to Marshall Renovations.';
});
// ---- init ----
seed.labor.forEach(function (d) {
laborRows.appendChild(makeRow('labor', d));
});
seed.material.forEach(function (d) {
materialRows.appendChild(makeRow('material', d));
});
recalc();
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Line-Item Quote Builder</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="quote" aria-labelledby="quote-title">
<header class="quote__head">
<div class="quote__brand">
<span class="quote__hazard" aria-hidden="true"></span>
<div>
<p class="quote__co">Ironside Builders & Co.</p>
<h1 id="quote-title">Estimate Worksheet</h1>
</div>
</div>
<dl class="quote__meta">
<div>
<dt>Quote #</dt>
<dd id="quote-no">EST-4821</dd>
</div>
<div>
<dt>Client</dt>
<dd>Marshall Renovations</dd>
</div>
<div>
<dt>Date</dt>
<dd>Jun 22, 2026</dd>
</div>
</dl>
</header>
<section class="card" aria-labelledby="labor-head">
<div class="card__top">
<h2 id="labor-head">Labor</h2>
<span class="badge badge--labor">Crew</span>
</div>
<div class="table" role="table" aria-label="Labor line items">
<div class="row row--head" role="row">
<span role="columnheader" class="col-desc">Description</span>
<span role="columnheader" class="col-qty">Qty</span>
<span role="columnheader" class="col-unit">Unit</span>
<span role="columnheader" class="col-price">Rate</span>
<span role="columnheader" class="col-total">Line total</span>
<span role="columnheader" class="col-rm" aria-label="Remove"></span>
</div>
<div class="rows" id="labor-rows"></div>
</div>
<button type="button" class="addrow" data-add="labor">+ Add labor row</button>
</section>
<section class="card" aria-labelledby="mat-head">
<div class="card__top">
<h2 id="mat-head">Materials</h2>
<span class="badge badge--mat">Supply</span>
</div>
<div class="table" role="table" aria-label="Material line items">
<div class="row row--head" role="row">
<span role="columnheader" class="col-desc">Description</span>
<span role="columnheader" class="col-qty">Qty</span>
<span role="columnheader" class="col-unit">Unit</span>
<span role="columnheader" class="col-price">Unit price</span>
<span role="columnheader" class="col-total">Line total</span>
<span role="columnheader" class="col-rm" aria-label="Remove"></span>
</div>
<div class="rows" id="material-rows"></div>
</div>
<button type="button" class="addrow" data-add="material">+ Add material row</button>
</section>
<section class="summary" aria-labelledby="sum-head">
<h2 id="sum-head" class="sr-only">Quote summary</h2>
<div class="summary__lines">
<div class="sline">
<span>Labor subtotal</span>
<span class="mono" id="sum-labor">$0.00</span>
</div>
<div class="sline">
<span>Materials subtotal</span>
<span class="mono" id="sum-mat">$0.00</span>
</div>
<div class="sline sline--base">
<span>Cost base</span>
<span class="mono" id="sum-base">$0.00</span>
</div>
<div class="sline sline--input">
<label for="markup">Markup %</label>
<div class="pct">
<input type="number" id="markup" min="0" step="0.5" value="15" inputmode="decimal" />
<span class="mono" id="sum-markup">$0.00</span>
</div>
</div>
<div class="sline sline--input">
<label for="contingency">Contingency %</label>
<div class="pct">
<input type="number" id="contingency" min="0" step="0.5" value="5" inputmode="decimal" />
<span class="mono" id="sum-cont">$0.00</span>
</div>
</div>
<div class="sline sline--input">
<label for="tax">Sales tax %</label>
<div class="pct">
<input type="number" id="tax" min="0" step="0.1" value="8.25" inputmode="decimal" />
<span class="mono" id="sum-tax">$0.00</span>
</div>
</div>
</div>
<div class="grand">
<span>Grand total</span>
<strong class="mono" id="grand-total">$0.00</strong>
</div>
<button type="button" class="send" id="send-quote">Send quote</button>
<p class="send__note" id="send-note" role="status" aria-live="polite"></p>
</section>
</main>
<template id="row-tpl">
<div class="row" role="row">
<span class="col-desc"><input type="text" class="f f-desc" aria-label="Description" /></span>
<span class="col-qty"><input type="number" class="f f-qty" min="0" step="0.25" inputmode="decimal" aria-label="Quantity" /></span>
<span class="col-unit"><input type="text" class="f f-unit" aria-label="Unit" /></span>
<span class="col-price"><input type="number" class="f f-price" min="0" step="0.01" inputmode="decimal" aria-label="Unit price" /></span>
<span class="col-total mono lt">$0.00</span>
<span class="col-rm"><button type="button" class="rm" aria-label="Remove row">×</button></span>
</div>
</template>
<script src="script.js"></script>
</body>
</html>Line-Item Quote Builder
A hard-hat estimate sheet for general contractors and trades. Add labor and material rows, each with an editable description, quantity, unit, and unit price; the line total recalculates the moment you type. Remove any row, or append fresh blank rows of either kind from the toolbar.
The summary panel rolls labor and material subtotals into a combined cost base, then layers a markup %, a contingency %, and sales tax % on top to produce the grand total. Every percentage input is live, so you can dial in a margin and watch the bottom line move. A send-quote action confirms inline with a generated quote number.
Built with vanilla JS and a sturdy steel-and-hi-vis palette — bold condensed headings, hazard accents, and chunky borders. Fully responsive down to a 520px stacked layout for tablets on site.
Illustrative UI only — not a substitute for a licensed estimator or contract document.