Pergola & Patio Cover Calculator

Give your patio cover or pergola company website a calculator that answers the first question every homeowner asks: roughly how much will it cost? The visitor says what they want from the cover and what it will stand on, picks a motorized louvered pergola, a solid insulated or W-pan patio cover, a lattice cover or a retractable canopy pergola, sets the width and projection in feet, the mounting and the wind or snow zone, and adds LED lighting, motorized screens or sliding glass, a sensor, heaters and a fan. The price is built per square foot with surcharges for insulated panels, a freestanding frame, a long projection, dark or wood-look colors and engineering zones, plus footings or anchors by the surface, installation with the permit and delivery by the mile. A prepayment discount, an optional promotion and a minimum job round it off, and the visitor books a free site visit with a PDF estimate in hand.

Home Services

Try the Calculator

Patio Cover & Pergola Estimate
Compare a louvered pergola, a solid or lattice patio cover and a retractable canopy pergola, size it in feet and see a price range with installation before the free site visit.
🏡 Your Patio
 
 
⛱️ Structure
 
For what you want from the cover we would start from the , but every structure here can be built on your patio.
📐 Size & Mounting
14ft
14 ft
824
12ft
12 ft
820
9ft
9 ft
812
 
 
Louvered pergola 14 ft × 12 ft = 168 sq ft, 9 ft under the beam, 2 posts.
✨ Lights, Sides & Comfort
 
 
🔧 Installation & Delivery
20mi
20 mi
0200
💰 Your Estimate
$ 16,918
+
$ 0
+
$ 3,885
+
$ 0

$ 20,803
before sales tax
Expect $18,700 – $22,900 (±10%); the final price is confirmed after the free site visit.
📅 Site visit within 5 business days → city permit with engineered drawings → installation 6 weeks from the signed contract.
📋 Book Your Free Site Visit
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
export function pergolaPatioCoverCalculator(form: FormTs) {
// Structure price per square foot of cover (width x projection), fabricated and delivered
const structureRates: Record<string, number> = {
'louvered': 95, // motorized louvered pergola: extruded aluminum, motor, gutters in the beams
'solid': 42, // solid aluminum patio cover with W-pan roof panels
'lattice': 30, // open lattice cover: rafters and 2x2 lattice tubes
'canopy': 55 // pergola with a motorized retractable fabric canopy
};
 
// Surcharges on the structure, in percent
const insulatedPanelsPct = 30; // 3-inch insulated panels instead of W-pan (solid cover only)
const freestandingPct = 20; // four posts, a back beam and heavier footings
const projectionLimitFt = 14; // beyond this projection the heavier beams are charged
const longProjectionPct = 12;
const darkColorPct = 6; // bronze or black powder coat
const woodgrainPct = 15; // wood-look powder coat
const zonePct: Record<string, number> = { 'standard': 0, 'wind': 10, 'snow': 15 };
 
// Options
const ledPerFt = 28; // dimmable LED strip in the beams, per linear foot of perimeter
const sidesPerSqft: Record<string, number> = { 'none': 0, 'screens': 32, 'glass': 110 };
const sensorPrice = 650; // rain and wind sensor
const heaterEach = 1400; // electric infrared heater with the circuit
const fanEach = 850; // fan beam, outdoor-rated fan and wiring
 
// Installation and delivery
const footingEach = 350; // poured concrete footing under one post
const anchorEach = 95; // anchoring a post to an existing slab
const installPct = 18; // crew on site, percent of structure and options
const permitPrice = 650; // city permit with engineered drawings and inspection
const hoaWeeks = 3; // HOA architectural review, on the schedule only
const freeDeliveryMiles = 40;
const deliveryPerMile = 3;
 
// Discounts, minimum and the estimate range
const prepayPct = 3; // payment in full before the installation
const promoPct = 0; // promotional discount on the whole order; 0 hides the banner
const promoText = 'LED lighting included';
const minimumJob = 6500;
const rangePct = 10; // the estimate is shown as a range of +/- this percent
const leadWeeks = 6; // from the signed contract to the installation
const siteVisitDays = 5; // free site visit within this many business days
const validityDays = 30;
 
const kindNames: Record<string, string> = {
'louvered': 'Louvered pergola',
'solid': 'Solid patio cover',
'lattice': 'Lattice cover',
'canopy': 'Retractable canopy pergola'
};
const goalToKind: Record<string, string> = { 'heat': 'solid', 'light': 'lattice', 'year': 'louvered', 'flex': 'canopy' };
const surfaceNames: Record<string, string> = {
'slab': 'Concrete patio',
'pavers': 'Pavers or flagstone',
'deck': 'Wood or composite deck',
'dirt': 'Lawn or dirt'
};
const colorNames: Record<string, string> = { 'white': 'White', 'adobe': 'Adobe', 'bronze': 'Bronze', 'black': 'Black', 'woodgrain': 'Wood-look' };
const zoneNames: Record<string, string> = { 'standard': 'Standard', 'wind': 'High-wind zone', 'snow': 'Snow country' };
const sideNames: Record<string, string> = { 'none': 'Open sides', 'screens': 'Motorized screens', 'glass': 'Sliding glass panels' };
const sideCounts: Record<string, number> = { 'front': 1, 'front_sides': 3, 'all': 4 };
 
const money = (amount: number): string => {
const whole = Math.round(Math.abs(amount));
let digits = '' + whole;
let grouped = '';
while (digits.length > 3) {
grouped = ',' + digits.slice(-3) + grouped;
digits = digits.slice(0, -3);
}
return (amount < 0 ? '-$' : '$') + digits + grouped;
};
const pick = (table: Record<string, unknown>, value: unknown, fallback: string): string =>
typeof value === 'string' && value in table ? value : fallback;
const feetInches = (ft: number): string => {
const whole = Math.floor(ft);
const inches = Math.round((ft - whole) * 12);
return inches === 0 ? whole + ' ft' : whole + ' ft ' + inches + ' in';
};
 
form.addRow(row => {
row.addTextPanel('header', {
computedValue: () => 'Patio Cover & Pergola Estimate',
customStyles: { 'font-size': '1.5rem', 'font-weight': '600', 'color': '#1e293b' }
});
});
 
form.addRow(row => {
row.addTextPanel('intro', {
computedValue: () => 'Compare a louvered pergola, a solid or lattice patio cover and a retractable canopy pergola, size it in feet and see a price range with installation before the free site visit.',
customStyles: { 'font-size': '0.95rem', 'color': '#64748b' }
});
});
 
form.addSpacer({ height: 16 });
 
const steps = form.addPages('steps', { heightMode: 'current-page' });
const patioPage = steps.addPage('patio');
const structurePage = steps.addPage('structure');
const optionsPage = steps.addPage('options');
const estimatePage = steps.addPage('estimate');
 
// Step 1: the patio, what for and on what, then the structure
const patioSection = patioPage.addSubform('patioDetails', { title: '🏡 Your Patio' });
 
patioSection.addRow(row => {
row.addRadioButton('goal', {
label: 'What do you want from the cover?',
tooltip: 'We suggest the structure to start from; you can pick another one below',
options: [
{ id: 'heat', name: 'Beat the heat - a cool patio at 3 p.m. in August' },
{ id: 'light', name: 'Filtered light - some sun through, the sky still there' },
{ id: 'year', name: 'An outdoor living room - sun, rain and cool evenings, all year' },
{ id: 'flex', name: 'Shade on demand - covered on hot days, open for the stars' }
],
defaultValue: 'year',
orientation: 'vertical',
isRequired: true,
// The goal picks the structure to start from; the visitor can still choose another one.
onValueChange: value => {
const suggested = goalToKind[pick(goalToKind, value, 'year')];
structureSection.radioButton('kind')?.setValue(suggested);
}
}, '1fr');
row.addRadioButton('surface', {
label: 'What will it stand on?',
tooltip: 'Decides whether the posts get concrete footings or are anchored to the slab',
options: [
{ id: 'slab', name: 'Concrete patio - posts anchored to the slab' },
{ id: 'pavers', name: 'Pavers or flagstone - footings under the pavers' },
{ id: 'deck', name: 'Wood or composite deck - footings under the deck' },
{ id: 'dirt', name: 'Lawn or dirt, or a patio still to be poured' }
],
defaultValue: 'slab',
orientation: 'vertical',
isRequired: true
}, '1fr');
});
 
const goal = form.computedValue(() => pick(goalToKind, patioSection.radioButton('goal')?.value(), 'year'));
const surface = form.computedValue(() => pick(surfaceNames, patioSection.radioButton('surface')?.value(), 'slab'));
const anchorsToSlab = form.computedValue(() => surface() === 'slab');
 
patioSection.addRow(row => {
row.addTextPanel('deckNote', {
computedValue: () => 'A deck needs support underneath: we do not bolt posts to the deck boards, but to concrete or to footings under the deck. On the site visit we check where they can go.',
isVisible: () => surface() === 'deck',
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' }
});
});
 
const structureSection = patioPage.addSubform('structureDetails', { title: '⛱️ Structure' });
 
structureSection.addRow(row => {
row.addRadioButton('kind', {
label: 'Type of cover',
options: () => [
{ id: 'louvered', name: 'Louvered pergola - motorized louvers: a roof when closed, stripes of shade when tilted (from $' + structureRates['louvered'] + '/sq ft)' },
{ id: 'solid', name: 'Solid patio cover - insulated or W-pan aluminum roof, sloped away from the house (from $' + structureRates['solid'] + '/sq ft)' },
{ id: 'lattice', name: 'Lattice cover - open 2x2 lattice on rafters: filtered light (from $' + structureRates['lattice'] + '/sq ft)' },
{ id: 'canopy', name: 'Retractable canopy pergola - fabric roof on guide rails (from $' + structureRates['canopy'] + '/sq ft)' }
],
defaultValue: 'louvered',
orientation: 'vertical',
isRequired: true
});
});
 
const kind = form.computedValue(() => pick(kindNames, structureSection.radioButton('kind')?.value(), 'louvered'));
 
structureSection.addRow(row => {
row.addTextPanel('suggestion', {
computedValue: () => goal() === kind()
? '✓ ' + kindNames[kind()] + ' is our pick for what you want from the cover.'
: 'For what you want from the cover we would start from the ' + (kindNames[goal()] || '').toLowerCase() + ', but every structure here can be built on your patio.',
customStyles: { 'font-size': '0.9rem', 'color': '#059669' }
});
});
 
structureSection.addRow(row => {
row.addDropdown('color', {
label: 'Frame color',
options: [
{ id: 'white', name: 'White (included)' },
{ id: 'adobe', name: 'Adobe (included)' },
{ id: 'bronze', name: 'Bronze (+' + darkColorPct + '%)' },
{ id: 'black', name: 'Black (+' + darkColorPct + '%)' },
{ id: 'woodgrain', name: 'Wood-look (+' + woodgrainPct + '%)' }
],
defaultValue: 'bronze',
isRequired: true
}, '1fr');
row.addDropdown('panels', {
label: 'Roof panels',
options: [
{ id: 'insulated', name: 'Insulated 3-inch panels (+' + insulatedPanelsPct + '%)' },
{ id: 'pan', name: 'W-pan roof (included)' }
],
defaultValue: 'insulated',
isVisible: () => kind() === 'solid'
}, '1fr');
row.addDropdown('fabric', {
label: 'Canopy fabric',
options: [
{ id: 'sand', name: 'Sand' },
{ id: 'gray', name: 'Gray' },
{ id: 'charcoal', name: 'Charcoal' },
{ id: 'navy', name: 'Navy' }
],
defaultValue: 'sand',
isVisible: () => kind() === 'canopy'
}, '1fr');
});
 
const color = form.computedValue(() => pick(colorNames, structureSection.dropdown('color')?.value(), 'bronze'));
const insulated = form.computedValue(() => kind() === 'solid' && structureSection.dropdown('panels')?.value() !== 'pan');
const fabric = form.computedValue(() => structureSection.dropdown('fabric')?.value() || 'sand');
 
// Step 2: size and mounting
const sizeSection = structurePage.addSubform('sizeDetails', { title: '📐 Size & Mounting' });
 
sizeSection.addRow(row => {
row.addSlider('width', {
label: 'Width along the house wall',
unit: 'ft',
min: 8,
max: 24,
step: 1,
defaultValue: 14
}, '1fr');
row.addSlider('projection', {
label: 'Projection from the wall',
unit: 'ft',
min: 8,
max: 20,
step: 1,
defaultValue: 12,
tooltip: 'Over ' + projectionLimitFt + ' ft the heavier beams add ' + longProjectionPct + '%'
}, '1fr');
});
 
sizeSection.addRow(row => {
row.addSlider('height', {
label: 'Height under the beam',
unit: 'ft',
min: 8,
max: 12,
step: 0.5,
defaultValue: 9
}, '1fr');
row.addRadioButton('mount', {
label: 'Mounting',
options: [
{ id: 'attached', name: 'Attached to the house - two posts in front, the back on a ledger (included)' },
{ id: 'freestanding', name: 'Freestanding - four posts, anywhere in the yard (+' + freestandingPct + '%)' }
],
defaultValue: 'attached',
orientation: 'vertical',
isRequired: true
}, '1fr');
});
 
sizeSection.addRow(row => {
row.addRadioButton('zone', {
label: 'Where does it stand?',
tooltip: 'The wind and snow ratings the local code asks for decide the beams and the footings',
options: [
{ id: 'standard', name: 'Standard - up to 115 mph wind, no snow load (included)' },
{ id: 'wind', name: 'High-wind zone - coastal or exposed, 130 mph and up (+' + zonePct['wind'] + '%)' },
{ id: 'snow', name: 'Snow country - mountain towns with a snow load (+' + zonePct['snow'] + '%)' }
],
defaultValue: 'standard',
orientation: 'vertical',
isRequired: true
});
});
 
const width = form.computedValue(() => sizeSection.slider('width')?.value() || 14);
const projection = form.computedValue(() => sizeSection.slider('projection')?.value() || 12);
const height = form.computedValue(() => sizeSection.slider('height')?.value() || 9);
const freestanding = form.computedValue(() => sizeSection.radioButton('mount')?.value() === 'freestanding');
const zone = form.computedValue(() => pick(zoneNames, sizeSection.radioButton('zone')?.value(), 'standard'));
const area = form.computedValue(() => width() * projection());
const posts = form.computedValue(() => freestanding() ? 4 : 2);
// The perimeter the LED runs along: the front, the sides and the back when there is no wall
const perimeter = form.computedValue(() => width() * (freestanding() ? 2 : 1) + 2 * projection());
 
sizeSection.addRow(row => {
row.addTextPanel('sizeSummary', {
computedValue: () => kindNames[kind()] + ' ' + width() + ' ft × ' + projection() + ' ft = ' + area() + ' sq ft, ' + feetInches(height()) + ' under the beam, ' + posts() + ' posts.',
customStyles: { 'font-size': '0.9rem', 'color': '#059669', 'font-weight': '500' }
});
});
 
// Step 3: options, then installation and delivery
const comfortSection = optionsPage.addSubform('comfortOptions', { title: '✨ Lights, Sides & Comfort' });
 
comfortSection.addRow(row => {
row.addCheckbox('led', {
label: 'LED strips in the beams ($' + ledPerFt + '/ft of beams)',
defaultValue: false
}, '1fr');
row.addRadioButton('sides', {
label: 'Closing the sides',
options: [
{ id: 'none', name: 'Open sides - just the roof' },
{ id: 'screens', name: 'Motorized zip screens ($' + sidesPerSqft['screens'] + '/sq ft of side)' },
{ id: 'glass', name: 'Sliding glass panels ($' + sidesPerSqft['glass'] + '/sq ft of side)' }
],
defaultValue: 'none',
orientation: 'vertical',
isRequired: true
}, '1fr');
});
 
const led = form.computedValue(() => comfortSection.checkbox('led')?.value() === true);
const sides = form.computedValue(() => pick(sideNames, comfortSection.radioButton('sides')?.value(), 'none'));
 
comfortSection.addRow(row => {
row.addRadioButton('whichSides', {
label: 'Which sides?',
tooltip: 'Against the house the wall takes the back; only a freestanding structure can be closed all around',
options: () => [
{ id: 'front', name: 'Front' },
{ id: 'front_sides', name: 'Front and both sides' },
...(freestanding() ? [{ id: 'all', name: 'All around' }] : [])
],
defaultValue: 'front_sides',
orientation: 'horizontal',
isVisible: () => sides() !== 'none'
});
});
 
const sideCount = form.computedValue(() => {
if (sides() === 'none') return 0;
const chosen = pick(sideCounts, comfortSection.radioButton('whichSides')?.value(), 'front_sides');
return Math.min(sideCounts[chosen] || 0, freestanding() ? 4 : 3);
});
// A sensor has something to do only when something can close or roll in
const sensorMakesSense = form.computedValue(() => kind() === 'louvered' || kind() === 'canopy' || sides() === 'screens');
const heaterCount = form.computedValue(() => width() > 14 ? 2 : 1);
// A canopy has nothing to hang a fan from
const fanFits = form.computedValue(() => kind() !== 'canopy');
const fanCount = form.computedValue(() => width() > 18 ? 2 : 1);
 
comfortSection.addRow(row => {
row.addCheckboxList('comfort', {
label: 'Comfort',
options: () => [
...(sensorMakesSense() ? [{ id: 'sensor', name: 'Rain and wind sensor - closes the louvers in rain, retracts the canopy and screens in a gust (+' + money(sensorPrice) + ')' }] : []),
{ id: 'heaters', name: (heaterCount() > 1 ? 'Two infrared heaters' : 'Infrared heater') + ' under the beam at the house (+' + money(heaterCount() * heaterEach) + ')' },
...(fanFits() ? [{ id: 'fan', name: (fanCount() > 1 ? 'Two ceiling fans' : 'Ceiling fan') + ' on a fan beam across the middle (+' + money(fanCount() * fanEach) + ')' }] : [])
],
orientation: 'vertical'
});
});
 
const comfort = () => comfortSection.checkboxList('comfort')?.value() || [];
const sensor = form.computedValue(() => sensorMakesSense() && comfort().includes('sensor'));
const heaters = form.computedValue(() => comfort().includes('heaters'));
const fan = form.computedValue(() => fanFits() && comfort().includes('fan'));
 
const installSection = optionsPage.addSubform('installation', { title: '🔧 Installation & Delivery' });
 
installSection.addRow(row => {
row.addCheckbox('installed', {
label: 'Installed by our crew (+' + installPct + '% of the structure, footings and the city permit)',
tooltip: 'Unchecked: delivered as a kit, you install and pull the permit yourself',
defaultValue: true
}, '1fr');
row.addCheckbox('hoa', {
label: 'My neighborhood has an HOA (about ' + hoaWeeks + ' weeks for the architectural review)',
defaultValue: false
}, '1fr');
});
 
installSection.addRow(row => {
row.addSlider('distance', {
label: 'Distance from our shop',
unit: 'mi',
min: 0,
max: 200,
step: 5,
defaultValue: 20,
tooltip: 'Up to ' + freeDeliveryMiles + ' mi we deliver free, beyond that $' + deliveryPerMile + ' per mile'
}, '1fr');
row.addCheckbox('prepay', {
label: 'Pay in full before the installation (' + prepayPct + '% discount)',
defaultValue: false
}, '1fr');
});
 
const installed = form.computedValue(() => installSection.checkbox('installed')?.value() === true);
const hoa = form.computedValue(() => installSection.checkbox('hoa')?.value() === true);
const distance = form.computedValue(() => installSection.slider('distance')?.value() || 0);
const prepay = form.computedValue(() => installSection.checkbox('prepay')?.value() === true);
 
// Pricing
const structureAmount = form.computedValue(() => {
const base = area() * (structureRates[kind()] || 0);
const forPanels = insulated() ? 1 + insulatedPanelsPct / 100 : 1;
const forMount = freestanding() ? 1 + freestandingPct / 100 : 1;
const forProjection = projection() > projectionLimitFt ? 1 + longProjectionPct / 100 : 1;
const forColor = color() === 'white' || color() === 'adobe' ? 1 : 1 + (color() === 'woodgrain' ? woodgrainPct : darkColorPct) / 100;
const forZone = 1 + (zonePct[zone()] || 0) / 100;
return base * forPanels * forMount * forProjection * forColor * forZone;
});
 
const ledAmount = form.computedValue(() => led() ? perimeter() * ledPerFt : 0);
// The sides go in order: the front by the width, the sides by the projection, the back of a freestanding one by the width again
const sidesArea = form.computedValue(() => {
const count = sideCount();
let total = 0;
if (count >= 1) total += width() * height();
if (count >= 3) total += 2 * projection() * height();
if (count >= 4) total += width() * height();
return total;
});
const sidesAmount = form.computedValue(() => sidesArea() * (sidesPerSqft[sides()] || 0));
const sensorAmount = form.computedValue(() => sensor() ? sensorPrice : 0);
const heatersAmount = form.computedValue(() => heaters() ? heaterCount() * heaterEach : 0);
const fanAmount = form.computedValue(() => fan() ? fanCount() * fanEach : 0);
const optionsAmount = form.computedValue(() => ledAmount() + sidesAmount() + sensorAmount() + heatersAmount() + fanAmount());
 
const footingsAmount = form.computedValue(() => installed() ? posts() * (anchorsToSlab() ? anchorEach : footingEach) : 0);
const permitAmount = form.computedValue(() => installed() ? permitPrice : 0);
const installAmount = form.computedValue(() => installed() ? (structureAmount() + optionsAmount()) * installPct / 100 : 0);
const deliveryAmount = form.computedValue(() => Math.max(0, distance() - freeDeliveryMiles) * deliveryPerMile);
 
const netAmount = form.computedValue(() => structureAmount() + optionsAmount() + footingsAmount() + permitAmount() + installAmount() + deliveryAmount());
const prepayDiscount = form.computedValue(() => prepay() ? netAmount() * prepayPct / 100 : 0);
const promoDiscount = form.computedValue(() => (netAmount() - prepayDiscount()) * promoPct / 100);
// The minimum holds last: a promotion may never take a job below what the crew costs
const total = form.computedValue(() => Math.max(Math.round(netAmount() - prepayDiscount() - promoDiscount()), minimumJob));
const priceFrom = form.computedValue(() => Math.round(total() * (1 - rangePct / 100) / 100) * 100);
const priceTo = form.computedValue(() => Math.round(total() * (1 + rangePct / 100) / 100) * 100);
 
const footingsText = () => anchorsToSlab()
? posts() + ' posts anchored to the slab'
: posts() + ' concrete footings' + (surface() === 'deck' ? ' (checked on the site visit)' : '');
const sidesText = () => sides() === 'none'
? 'open sides'
: (sideNames[sides()] || '').toLowerCase() + ': ' + (sideCount() === 1 ? 'front' : sideCount() === 3 ? 'front and both sides' : 'all around');
const structureText = () => kindNames[kind()] + ', ' + (colorNames[color()] || '').toLowerCase()
+ (kind() === 'solid' ? (insulated() ? ', insulated panels' : ', W-pan roof') : '')
+ (kind() === 'canopy' ? ', ' + fabric() + ' fabric' : '');
 
// Step 4: the estimate and the contact details
const summarySection = estimatePage.addSubform('summary', { title: '💰 Your Estimate', isCollapsible: false });
 
summarySection.addRow(row => {
row.addTextPanel('promo', {
computedValue: () => '🎉 ' + promoText + ': the ' + promoPct + '% discount is already in the amount below.',
isVisible: () => promoDiscount() > 0,
customStyles: { 'font-size': '0.9rem', 'color': '#166534', 'background': '#dcfce7', 'padding': '10px', 'border-radius': '6px' }
});
});
 
summarySection.addRow(row => {
row.addPriceDisplay('structurePrice', {
label: () => kindNames[kind()] + ', ' + area() + ' sq ft',
computedValue: () => Math.round(structureAmount()),
decimals: 0
}, '1fr');
row.addPriceDisplay('optionsPrice', {
label: 'Lights, sides and comfort',
computedValue: () => Math.round(optionsAmount()),
decimals: 0,
prefix: '+'
}, '1fr');
});
 
summarySection.addRow(row => {
row.addPriceDisplay('installPrice', {
label: () => installed() ? 'Installation, footings and permit' : 'Delivered as a kit, you install',
computedValue: () => Math.round(installAmount() + footingsAmount() + permitAmount()),
decimals: 0,
prefix: '+'
}, '1fr');
row.addPriceDisplay('deliveryPrice', {
label: () => 'Delivery, ' + distance() + ' mi from our shop',
computedValue: () => Math.round(deliveryAmount()),
decimals: 0,
prefix: '+'
}, '1fr');
});
 
summarySection.addRow(row => {
row.addPriceDisplay('discounts', {
label: () => 'Discounts' + (prepay() ? ' (prepayment ' + prepayPct + '%)' : ''),
computedValue: () => -Math.round(prepayDiscount() + promoDiscount()),
decimals: 0,
variant: 'success',
isVisible: () => prepayDiscount() + promoDiscount() > 0
});
});
 
summarySection.addSpacer({ showLine: true, lineStyle: 'solid', lineColor: '#e2e8f0' });
 
summarySection.addRow(row => {
row.addPriceDisplay('total', {
label: 'Cover with installation, about',
computedValue: () => total(),
decimals: 0,
variant: 'large',
suffix: 'before sales tax'
});
});
 
summarySection.addRow(row => {
row.addTextPanel('range', {
computedValue: () => 'Expect ' + money(priceFrom()) + ' – ' + money(priceTo()) + ' (±' + rangePct + '%); the final price is confirmed after the free site visit.'
+ (total() > Math.round(netAmount() - prepayDiscount() - promoDiscount()) ? ' The estimate was raised to our minimum job of ' + money(minimumJob) + '.' : ''),
customStyles: { 'font-size': '0.9rem', 'color': '#475569' }
});
});
 
summarySection.addRow(row => {
row.addTextPanel('schedule', {
computedValue: () => '📅 Site visit within ' + siteVisitDays + ' business days'
+ (hoa() ? ' → HOA review, about ' + hoaWeeks + ' weeks' : '')
+ (installed() ? ' → city permit with engineered drawings' : ' → permit on your side, we supply the drawings')
+ ' → installation ' + leadWeeks + ' weeks from the signed contract.',
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' }
});
});
 
const contactSection = estimatePage.addSubform('contact', { title: '📋 Book Your Free Site Visit', isCollapsible: false });
 
contactSection.addRow(row => {
row.addTextbox('name', { label: 'Full name', placeholder: 'John Smith', isRequired: true }, '1fr');
row.addTextbox('phone', { label: 'Phone', placeholder: '(555) 123-4567', isRequired: true }, '1fr');
});
 
contactSection.addRow(row => {
row.addEmail('email', { label: 'Email', placeholder: 'john@example.com', isRequired: true }, '1fr');
row.addTextbox('city', { label: 'City, state', placeholder: 'Mesa, AZ', isRequired: true }, '1fr');
row.addTextbox('zip', { label: 'ZIP code', placeholder: '85202', maxLength: 10, isRequired: true }, '1fr');
});
 
contactSection.addRow(row => {
row.addTextarea('notes', {
label: 'Anything else we should know?',
placeholder: 'E.g. a gutter or a window above the patio, an outlet on the wall, a second-story balcony, a pool nearby',
rows: 3
});
});
 
form.configurePdf('estimate', pdf => {
pdf.configure({
filename: 'patio-cover-estimate',
pageSize: 'Letter',
allowUserDownload: true,
downloadButtonLabel: 'Download your estimate (PDF)',
header: {
title: 'Patio cover estimate and configuration',
subtitle: kindNames[kind()] + ' ' + width() + ' ft × ' + projection() + ' ft'
},
footer: {
text: 'Estimate based on the configuration from the form, valid for ' + validityDays + ' days. A binding quote follows the free site visit within ' + siteVisitDays + ' business days.',
showPageNumbers: true
}
});
 
pdf.addSection('Customer', section => {
section.addRow(row => {
row.addField('Name', contactSection.textbox('name')?.value() || '');
row.addField('Phone', contactSection.textbox('phone')?.value() || '');
row.addField('Email', contactSection.email('email')?.value() || '');
row.addField('Location', (contactSection.textbox('city')?.value() || '') + ' ' + (contactSection.textbox('zip')?.value() || ''));
});
});
 
pdf.addSection('Your cover', section => {
section.addRow(row => {
row.addField('Structure', structureText());
row.addField('Size', width() + ' ft × ' + projection() + ' ft (' + area() + ' sq ft)');
row.addField('Height under the beam', feetInches(height()));
row.addField('Mounting', freestanding() ? 'Freestanding' : 'Attached to the house');
});
section.addRow(row => {
row.addField('Surface', surfaceNames[surface()] || '');
row.addField('Zone', zoneNames[zone()] || '');
row.addField('LED lighting', led() ? 'yes, ' + Math.round(perimeter()) + ' ft in the beams' : 'no');
row.addField('Sides', sidesText());
});
section.addRow(row => {
row.addField('Rain and wind sensor', sensor() ? 'yes' : 'no');
row.addField('Heaters', heaters() ? heaterCount() + ' pcs' : 'no');
row.addField('Ceiling fan', fan() ? fanCount() + ' pcs' : 'no');
row.addField('Paperwork', (installed() ? 'city permit by us' : 'permit by the owner') + (hoa() ? ', HOA approval' : ''));
});
});
 
pdf.addSection('Estimate', section => {
const rows: string[][] = [
[kindNames[kind()] + ', ' + area() + ' sq ft', money(structureAmount())]
];
if (ledAmount() > 0) rows.push(['LED lighting, ' + Math.round(perimeter()) + ' ft of beams', money(ledAmount())]);
if (sidesAmount() > 0) rows.push([sideNames[sides()] + ', ' + Math.round(sidesArea()) + ' sq ft', money(sidesAmount())]);
if (sensorAmount() > 0) rows.push(['Rain and wind sensor', money(sensorAmount())]);
if (heatersAmount() > 0) rows.push(['Infrared heaters, ' + heaterCount() + ' × ' + money(heaterEach), money(heatersAmount())]);
if (fanAmount() > 0) rows.push(['Ceiling fan, ' + fanCount() + ' × ' + money(fanEach), money(fanAmount())]);
rows.push([installed() ? 'Installation (' + installPct + '%), ' + footingsText() + ', permit' : 'Delivered as a kit, you install', money(installAmount() + footingsAmount() + permitAmount())]);
rows.push(['Delivery, ' + distance() + ' mi (free up to ' + freeDeliveryMiles + ' mi)', money(deliveryAmount())]);
if (prepayDiscount() > 0) rows.push(['Prepayment discount ' + prepayPct + '%', '-' + money(prepayDiscount())]);
if (promoDiscount() > 0) rows.push([promoText + ' ' + promoPct + '%', '-' + money(promoDiscount())]);
section.addTable(['Item', 'Amount'], rows);
section.addDivider();
section.addText('Estimate: ' + money(priceFrom()) + ' – ' + money(priceTo()) + ' before sales tax, with installation and delivery. Range ±' + rangePct + '%, price confirmed after the site visit.');
});
 
pdf.addSection('What happens next', section => {
section.addText('1. Free site visit within ' + siteVisitDays + ' business days: we confirm the size, the surface and the wall.');
section.addText('2. ' + (hoa() ? 'HOA architectural review, about ' + hoaWeeks + ' weeks, with our drawings. ' : '') + (installed() ? 'The city permit is ours, with the engineered drawings and the final inspection.' : 'The permit is on your side; we supply the engineered drawings.'));
section.addText('3. After the site visit you receive the final quote and the contract; the color and the options can change until it is signed.');
section.addText('4. Installation ' + leadWeeks + ' weeks from the signed contract.');
const notes = contactSection.textarea('notes')?.value();
if (notes) {
section.addSpacer(8);
section.addText('Notes: ' + notes);
}
});
});
 
form.configureSubmitButton({ label: 'Send and book my free site visit' });
 
form.configureCompletionScreen({
type: 'text',
title: 'We have your request',
message: 'We will call to schedule the free site visit within ' + siteVisitDays + ' business days. The size, the structure and the options you chose are the starting point for your quote.'
});
}
 

Frequently Asked Questions

Can I change the price per square foot for each type of cover?

Yes. Every rate is a named constant at the top of the calculator: the per-square-foot price of a louvered pergola, a solid cover, a lattice cover and a canopy pergola, the surcharges for insulated panels, a freestanding frame, a long projection, dark colors and wind or snow zones, the options and the installation. Change the numbers and the whole form follows.

Why does the calculator show a price range instead of a fixed price?

Patio covers are priced after a site visit: the wall, the slab or the footings and the local code all change the job. The calculator gives a range of plus or minus ten percent around the computed estimate so that visitors get an honest number without you committing to a price before you have measured the patio. You can narrow or widen the range in the code.

How does the calculator handle footings, permits and HOA approval?

The surface the visitor picks decides whether posts are anchored to a concrete slab or need poured footings, each priced per post. Installation by your crew adds a percentage of the structure and the city permit with engineered drawings. An HOA checkbox adds the architectural review to the schedule the visitor sees, without changing the price.

Can I remove structures or options I do not offer?

Yes. Remove a structure from the radio button list and its rate, or delete an option such as sliding glass panels or the ceiling fan. You can also add your own options, for example a privacy wall or a misting system, by following the pattern of the existing ones.

What happens after the visitor submits the form?

The visitor leaves their name, phone, email and location to book a free site visit, and can download a PDF with their configuration and the estimate. You receive the lead with every choice they made, so the site visit starts from a configuration instead of from zero.