A freelance quote template is a page with the same parts every time: who it is for, line items with a quantity and a rate, a subtotal, a total, and how long the price holds. The page below does the sums for you.
When a client asks to drop one item or add another, you click, read the new total and send. You do not export a new PDF.
Try it. Untick the logo concepts, press + on the revision rounds, tick the brand guide, then add a line. The totals follow every click.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Quote Q-0042</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; background: #f3f4f6; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; color: #1f2937; line-height: 1.45; }
.page { max-width: 560px; margin: 0 auto; background: #fff; min-height: 100vh; padding: 22px 18px 26px; }
h1 { margin: 0; font-size: 24px; line-height: 1.2; }
.meta { margin: 2px 0 0; font-size: 14px; color: #6b7280; }
.head { padding-bottom: 14px; border-bottom: 2px solid #1f2937; margin-bottom: 4px; }
.hint { margin: 10px 0 0; font-size: 14px; color: #4b5563; }
.row { display: grid; grid-template-columns: 30px 1fr auto; grid-template-areas: "tick what amt" ". step ."; gap: 2px 10px; padding: 12px 0; border-bottom: 1px solid #eef0f3; align-items: center; }
.row input[type=checkbox] { grid-area: tick; width: 22px; height: 22px; margin: 0; accent-color: #1d4ed8; }
.what { grid-area: what; min-width: 0; }
.name { display: block; font-size: 16px; font-weight: 600; overflow-wrap: anywhere; }
.calc { display: block; font-size: 14px; color: #6b7280; }
.amt { grid-area: amt; font-size: 16px; font-weight: 600; text-align: right; font-variant-numeric: tabular-nums; }
.step { grid-area: step; display: flex; align-items: center; gap: 8px; margin-top: 6px; }
.step button { width: 40px; height: 40px; border-radius: 10px; border: 1px solid #d1d5db; background: #fff; font-size: 20px; cursor: pointer; }
.step button:active { background: #eef2ff; }
.step .q { min-width: 70px; text-align: center; font-size: 14px; font-weight: 400; }
.row.off .name, .row.off .calc, .row.off .amt, .row.off .q { opacity: .4; text-decoration: line-through; }
#add { margin-top: 12px; height: 44px; padding: 0 16px; border: 1px dashed #9ca3af; border-radius: 10px; background: #fff; font-size: 15px; cursor: pointer; color: #1d4ed8; }
.sum { margin-top: 18px; background: #f9fafb; border-radius: 12px; padding: 14px 16px; }
.sum div { display: flex; justify-content: space-between; font-size: 15px; padding: 2px 0; font-variant-numeric: tabular-nums; }
.sum .big { font-size: 22px; font-weight: 700; padding-top: 8px; margin-top: 6px; border-top: 1px solid #e5e7eb; }
.fine { margin: 14px 0 0; font-size: 14px; color: #6b7280; }
</style>
</head>
<body>
<div class="page">
<div class="head">
<h1>Quote Q-0042</h1>
<p class="meta">Linden Studio, for Marlow Bakery</p>
<p class="meta">Valid for 14 days from the day it was sent</p>
</div>
<p class="hint">Tick a line in or out. Use + and - to change a quantity.</p>
<div id="lines">
<div class="row" data-rate="70" data-qty="1" data-unit="hour">
<input type="checkbox" checked aria-label="Include this line">
<div class="what"><span class="name">Kickoff call and brief</span><span class="calc"></span></div>
<span class="amt"></span>
<div class="step"><button type="button" data-d="-1" aria-label="Less">-</button><span class="q"></span><button type="button" data-d="1" aria-label="More">+</button></div>
</div>
<div class="row" data-rate="150" data-qty="3" data-unit="concept">
<input type="checkbox" checked aria-label="Include this line">
<div class="what"><span class="name">Logo concepts</span><span class="calc"></span></div>
<span class="amt"></span>
<div class="step"><button type="button" data-d="-1" aria-label="Less">-</button><span class="q"></span><button type="button" data-d="1" aria-label="More">+</button></div>
</div>
<div class="row" data-rate="80" data-qty="2" data-unit="round">
<input type="checkbox" checked aria-label="Include this line">
<div class="what"><span class="name">Revision rounds</span><span class="calc"></span></div>
<span class="amt"></span>
<div class="step"><button type="button" data-d="-1" aria-label="Less">-</button><span class="q"></span><button type="button" data-d="1" aria-label="More">+</button></div>
</div>
<div class="row" data-rate="240" data-qty="1" data-unit="guide">
<input type="checkbox" aria-label="Include this line">
<div class="what"><span class="name">Brand guide, 4 pages (optional)</span><span class="calc"></span></div>
<span class="amt"></span>
<div class="step"><button type="button" data-d="-1" aria-label="Less">-</button><span class="q"></span><button type="button" data-d="1" aria-label="More">+</button></div>
</div>
</div>
<button id="add" type="button">+ Add a line</button>
<div class="sum">
<div><span>Subtotal</span><span id="sub"></span></div>
<div id="taxrow"><span>Tax (<span id="taxpct"></span>%)</span><span id="tax"></span></div>
<div class="big"><span>Total</span><span id="total"></span></div>
<div><span>Deposit to start (<span id="deppct"></span>%)</span><span id="dep"></span></div>
</div>
<p class="fine">Not included: printing, stock photos, extra revision rounds. Reply to the message this link came in to accept.</p>
</div>
<script>
// ---- Change these two numbers. Set TAX_PERCENT to 0 to hide the tax line. ----
const TAX_PERCENT = 8;
const DEPOSIT_PERCENT = 30;
// ------------------------------------------------------------------------------
const lines = document.getElementById('lines');
const template = lines.firstElementChild.cloneNode(true);
const cents = (n) => Math.round(n * 100) / 100;
const money = (n) => '$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
function update() {
let sub = 0;
lines.querySelectorAll('.row').forEach((row) => {
const qty = Number(row.dataset.qty), rate = Number(row.dataset.rate);
const amount = cents(qty * rate);
const on = row.querySelector('input[type=checkbox]').checked;
row.classList.toggle('off', !on);
row.querySelector('.q').textContent = qty + ' ' + row.dataset.unit + (qty === 1 ? '' : 's');
row.querySelector('.calc').textContent = money(rate) + ' per ' + row.dataset.unit;
row.querySelector('.amt').textContent = money(amount);
if (on) sub += amount;
});
const tax = cents(sub * TAX_PERCENT / 100);
document.getElementById('sub').textContent = money(sub);
document.getElementById('taxpct').textContent = TAX_PERCENT;
document.getElementById('tax').textContent = money(tax);
document.getElementById('taxrow').hidden = TAX_PERCENT === 0;
document.getElementById('total').textContent = money(cents(sub + tax));
document.getElementById('deppct').textContent = DEPOSIT_PERCENT;
document.getElementById('dep').textContent = money(cents((sub + tax) * DEPOSIT_PERCENT / 100));
}
lines.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-d]');
if (!btn) return;
const row = btn.closest('.row');
row.dataset.qty = Math.max(0, Number(row.dataset.qty) + Number(btn.dataset.d));
update();
});
lines.addEventListener('change', update);
document.getElementById('add').addEventListener('click', () => {
const row = template.cloneNode(true);
row.dataset.qty = 1;
row.dataset.rate = 50;
row.dataset.unit = 'item';
row.querySelector('input[type=checkbox]').checked = true;
const name = row.querySelector('.name');
name.textContent = 'New item';
name.contentEditable = 'true';
lines.appendChild(row);
update();
});
update();
</script>
</body>
</html>
The studio, the client and every price are made up. Replace them with your own.
What goes in a quote
Freelancers ask this in plain words, for example on Quora: how do you send a new client an estimate and invoice? This guide covers the quote half. Keep the same parts in the same order, so you never forget one.
| Part | What to put in it |
|---|---|
| Header | Quote number, your name, the client's name, the date |
| Line items | One row per deliverable: description, quantity, unit, rate, amount |
| Optional lines | Extras the client can take or leave, shown as tick boxes |
| Subtotal and tax | Tax on its own line only if you charge it; check the rule where you work |
| Total and deposit | One number the client can say aloud, and the deposit if you ask for one |
| Validity | How long the price holds, for example 14 days from sending |
| Not included | What would cost extra, one short sentence each |
A quote and an invoice are different documents. For the second one, see how to send an invoice to a client.
Make it with AI
An AI chat can write this page in one reply. Paste this prompt into ChatGPT, Claude or Gemini, and put your real lines in the last part.
Make one self-contained HTML page: a quote for a client.
- Header: quote number, my name, client name, date.
- A list of line items: description, quantity, unit, rate, amount.
Keep the numbers in plain data attributes so I can edit them.
- Each line has a tick box to include it. Optional lines start unticked.
- Plus and minus buttons change a quantity. Never below zero.
- An "Add a line" button.
- Subtotal, a tax line (rate in one constant, hidden at 0),
total, and a deposit (percent in one constant).
- Show "valid for 14 days" and a short "not included" note.
- No external libraries, fonts or images. No localStorage.
- It must work on a phone, 390 px wide.
- Do not invent names, prices or dates. Write TODO where I gave none.
My lines: (list them here with quantity and rate)
Then check the result before you trust it:
- Do the sums for one row and for the total on a calculator. A page can look right and add up wrong.
- Press + once and tick one line. The total should move by exactly that amount.
- Search the page for TODO and replace each one with your own value.
- Check the currency symbol, and set the tax rate yourself.
- Look at it at phone width. Every line and button should be readable.
If the reply shows as code instead of a page, see AI HTML not rendering.
Fix it by clicking, before you send
Clicks cover a quantity and an optional line. A rate or a description needs typing. This second page is a working sheet: type into the cells, remove a line with the cross, and add a discount.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Quote sheet</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; background: #f3f4f6; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; color: #1f2937; line-height: 1.45; }
.page { max-width: 600px; margin: 0 auto; background: #fff; min-height: 100vh; padding: 20px 16px 26px; }
h1 { margin: 0 0 2px; font-size: 22px; }
.sub { margin: 0 0 14px; font-size: 14px; color: #6b7280; }
.row { display: grid; grid-template-columns: 64px 1fr 1fr 38px; grid-template-areas: "desc desc desc desc" "qty rate amt del"; gap: 6px 8px; align-items: center; padding: 10px 0; border-bottom: 1px solid #eef0f3; }
.row .desc { grid-area: desc; }
.row .qty { grid-area: qty; }
.row .rate { grid-area: rate; }
.row .amt { grid-area: amt; text-align: right; font-weight: 600; font-variant-numeric: tabular-nums; font-size: 15px; }
.row .del { grid-area: del; }
input[type=text], input[type=number] { width: 100%; height: 40px; padding: 0 10px; border: 1px solid #d1d5db; border-radius: 8px; font: inherit; font-size: 15px; background: #fff; }
.del, #add { border: 1px solid #d1d5db; background: #fff; border-radius: 8px; cursor: pointer; font: inherit; font-size: 15px; }
.del { height: 40px; color: #b91c1c; }
#add { margin-top: 12px; height: 42px; padding: 0 14px; color: #1d4ed8; border-style: dashed; }
.opts { display: flex; gap: 12px; margin-top: 16px; }
.opts label { flex: 1; font-size: 14px; color: #4b5563; }
.opts input { margin-top: 4px; }
.sum { margin-top: 14px; background: #f9fafb; border-radius: 12px; padding: 12px 16px; }
.sum div { display: flex; justify-content: space-between; font-size: 15px; padding: 2px 0; font-variant-numeric: tabular-nums; }
.sum .big { font-size: 21px; font-weight: 700; padding-top: 8px; margin-top: 6px; border-top: 1px solid #e5e7eb; }
h2 { margin: 18px 0 6px; font-size: 15px; }
pre { margin: 0; padding: 12px; background: #111827; color: #e5e7eb; border-radius: 10px; font: 13px/1.5 ui-monospace, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
</style>
</head>
<body>
<div class="page">
<h1>Quote sheet</h1>
<p class="sub">Type a description, a quantity and a rate. The amounts and the text below follow.</p>
<div id="lines"></div>
<button id="add" type="button">+ Add a line</button>
<div class="opts">
<label>Discount %<input id="disc" type="number" min="0" max="100" step="1" value="10"></label>
<label>Deposit %<input id="depin" type="number" min="0" max="100" step="5" value="25"></label>
</div>
<div class="sum">
<div><span>Subtotal</span><span id="sub"></span></div>
<div><span>Discount</span><span id="discamt"></span></div>
<div class="big"><span>Total</span><span id="total"></span></div>
<div><span>Deposit</span><span id="dep"></span></div>
</div>
<h2>The same quote as plain text, for an email or a chat message</h2>
<pre id="text"></pre>
</div>
<script>
// Starting lines. Replace them with your own.
const START = [
['Menu photography, half day', 1, 320],
['Photo editing', 12, 12],
['Usage licence, web and print', 1, 150],
];
const lines = document.getElementById('lines');
const cents = (n) => Math.round(n * 100) / 100;
const money = (n) => '$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const num = (v) => { const n = parseFloat(v); return isNaN(n) || n < 0 ? 0 : n; };
function addRow(desc, qty, rate) {
const row = document.createElement('div');
row.className = 'row';
row.innerHTML =
'<input class="desc" type="text" aria-label="Description">' +
'<input class="qty" type="number" min="0" step="1" aria-label="Quantity">' +
'<input class="rate" type="number" min="0" step="1" aria-label="Rate">' +
'<span class="amt"></span>' +
'<button class="del" type="button" aria-label="Remove this line">×</button>';
row.querySelector('.desc').value = desc;
row.querySelector('.qty').value = qty;
row.querySelector('.rate').value = rate;
lines.appendChild(row);
}
function update() {
let sub = 0, text = [];
lines.querySelectorAll('.row').forEach((row) => {
const q = num(row.querySelector('.qty').value), r = num(row.querySelector('.rate').value);
const amount = cents(q * r);
row.querySelector('.amt').textContent = money(amount);
sub += amount;
text.push(row.querySelector('.desc').value + ': ' + q + ' x ' + money(r) + ' = ' + money(amount));
});
const d = Math.min(num(document.getElementById('disc').value), 100);
const discount = cents(sub * d / 100);
const total = cents(sub - discount);
const dep = cents(total * Math.min(num(document.getElementById('depin').value), 100) / 100);
document.getElementById('sub').textContent = money(sub);
document.getElementById('discamt').textContent = '-' + money(discount);
document.getElementById('total').textContent = money(total);
document.getElementById('dep').textContent = money(dep);
text.push('Subtotal: ' + money(sub), 'Discount (' + d + '%): -' + money(discount), 'Total: ' + money(total), 'Deposit to start: ' + money(dep));
document.getElementById('text').textContent = text.join('\n');
}
lines.addEventListener('input', update);
lines.addEventListener('click', (e) => {
const del = e.target.closest('.del');
if (del) { del.closest('.row').remove(); update(); }
});
document.getElementById('disc').addEventListener('input', update);
document.getElementById('depin').addEventListener('input', update);
document.getElementById('add').addEventListener('click', () => { addRow('New item', 1, 0); update(); });
START.forEach((s) => addRow(s[0], s[1], s[2]));
update();
</script>
</body>
</html>
The plain-text box at the bottom is for a message where a link is not welcome. Copy it into the body of an email.

Here is what NOS keeps. When you, the owner, work in View mode, your ticks and the rows you add are saved. A visitor's are not.
Treat typed values as a scratchpad. To keep a number, change it in the code, or ask your AI to change it, before you create the link.
Send it as a link
Once the sums pass your check, paste the finished HTML into a NOS document and choose Create share link. Put the address in your email or chat message. HTML to link walks through the steps.

The client opens it in a browser, on a phone or a laptop, with no account. The page renders as written and its scripts run, so the totals work. They cannot edit your page.

If you fix the quote later, change the code and the same link shows the new version. Add one line near the top saying what changed and when, so the client knows the figures moved.
An "Accept" button cannot send anything to you, because sending a form to a server is blocked. Ask for a reply to your message instead. Anyone with the link can open the page, so leave out what you would not hand over on paper.
For the wording and timing of the message around the link, read send a quote to a client and quote link.
Link, file or PDF
| PDF export | HTML file attached | Link from NOS | |
|---|---|---|---|
| Fix a line | Edit, export again, send a new file | Edit, send a new file | Edit the code; same link |
| Totals | Fixed text | Calculated if the file is opened in a browser | Calculated on the page |
| Client tries an optional line | No | Only after opening the file | Yes, not saved |
| Versions in the inbox | One per fix | One per fix | One address |
The point is the second row. A PDF holds numbers as fixed text. A page can hold the rule that makes them. For more on why a link beats a file, see why links beat attachments.
When it does not work
| What you see | Cause | Fix |
|---|---|---|
| The preview shows code, not a page | The chat text or fences came along with the HTML | Paste only the HTML, from the first tag to the last |
| A total shows NaN or $0.00 | A quantity or rate is empty or has a symbol in it | Use plain numbers; put the symbol in the display code |
| The total differs from your calculator | Tax or discount is applied in a different order | Decide the order, then tell the AI |
| A tick or added row is gone for the client | A visitor's changes are not saved | Make the change yourself in View mode, or in the code |
| A typed number is gone after reopening | Typed values are not what is saved | Change the number in the code |
| An Accept button does nothing | Sending a form to a server is blocked | Ask for a reply by email |
| A logo does not show | Images from other sites are blocked | Draw it as an inline SVG, or leave it out |