Guide

Configurator Embed Kit: Airtable + Google Sheets/Apps Script

Copy‑paste embeds, guardrail parameters, performance‑safe wrappers, and a Google fallback you can ship today. Built for solo operators publishing a self‑serve pricing configurator that won’t break on mobile or get blocked by host policies.

Ship a self‑serve pricing configurator that works on real websites, protects your margins, and feeds clean data into your quote/proposal flow. This kit gives you copy‑paste embeds, guardrail parameters, mobile/host fixes, and a Google fallback (view‑only and fully interactive) you can deploy today.

1) Choose your path fast

Pick the path that fits your stack and host:

  • Airtable Form (recommended for input flows): light, responsive, supports conditional logic. Easiest to style in a page wrapper.
  • Airtable View (read‑only catalog or packaged options): great for "choose a package" browsing; note mobile toolbar quirks.
  • Airtable Interface (Business/Enterprise only): polished single‑page UI; embed where allowed.
  • Fallbacks when iframes are blocked: Google Sheets “Publish to web” (view‑only) or a Google Apps Script Web App (interactive, Sheets‑backed).

2) Airtable embed: copy‑paste snippets (forms, views, interfaces)

Use Airtable’s Share → Embed flow to grab the iframe. Wrap with a responsive container, set explicit height to avoid double scroll, and lazy‑load offscreen.

A) Form embed (works on all plans)

<!-- Container controls width; height lives on iframe -->
<div style="max-width: 940px; margin: 0 auto;">
  <iframe
    class="airtable-embed airtable-dynamic-height"
    src="https://airtable.com/embed/shrXXXXXXXXXXXXXX?backgroundColor=teal"
    frameborder="0"
    onmousewheel="" 
    width="100%"
    height="820"
    loading="lazy"
    style="background: #fff; border: 1px solid #e5e7eb; border-radius: 8px;">
  </iframe>
  <script async src="https://static.airtable.com/js/embed/embed_snippet_v1.js"></script>
</div>

Defaults that save time:

  • Height: start at 780–900px for forms, 1000–1300px for views. Bump if you see nested scroll.
  • Keep airtable-dynamic-height to reduce double scroll, but still set a generous height.
  • Add loading="lazy" for below‑the‑fold embeds.

B) View embed (catalog/bundles)

<div style="max-width: 1100px; margin: 0 auto;">
  <iframe
    class="airtable-embed airtable-dynamic-height"
    src="https://airtable.com/embed/appXXXXXXXXXXXXXX/tblXXXXXXXXXXXXXX?backgroundColor=teal&viewControls=on"
    frameborder="0"
    width="100%"
    height="1180"
    loading="lazy"
    style="background: #fff; border: 1px solid #e5e7eb; border-radius: 8px;">
  </iframe>
  <script async src="https://static.airtable.com/js/embed/embed_snippet_v1.js"></script>
</div>

Plan note: Interface page embeds require Business/Enterprise. If you’re on Free/Team, use Form/View embeds.

3) Margin guardrails: prefill + hide (copy‑paste cheatsheet)

Protect margin by passing non‑editable thresholds into the public form. Use URL parameters to prefill and hide fields.

Cheatsheet

  • Prefill a field: ?prefill_[Field Name]=[Value]
  • Hide a field: &hide_[Field Name]=true
  • Multiple fields: chain with &
  • Field names must match exactly (URL‑encode spaces and special chars).

Example: pass a minimum viable price, ops buffer, and a hidden Offer ID.

https://airtable.com/shrXXXXXXXXXXXXXX?
  prefill_Client+Email=alex%40client.com&
  prefill_Minimum+Price=1250&
  prefill_Ops+Buffer+Pct=0.18&
  prefill_Offer+ID=Q2-BUNDLE-03&
  hide_Minimum+Price=true&
  hide_Ops+Buffer+Pct=true&
  hide_Offer+ID=true

Generate safely in code (avoids encoding mistakes):

const baseUrl = "https://airtable.com/shrXXXXXXXXXXXXXX";
const params = new URLSearchParams({
  "prefill_Client Email": "alex@client.com",
  "prefill_Minimum Price": 1250,
  "prefill_Ops Buffer Pct": 0.18,
  "prefill_Offer ID": "Q2-BUNDLE-03",
  "hide_Minimum Price": true,
  "hide_Ops Buffer Pct": true,
  "hide_Offer ID": true
});
const embedUrl = `${baseUrl}?${params.toString()}`;

Tips

  • Compute guardrails server‑side (or in your CMS) and pass them as prefills. Don’t expose formulas in the public form.
  • In embedded public views, filters that reference hidden fields aren’t supported—avoid that pattern or surface the field in the view.
  • Pair this with an automation that rejects or adjusts any quote where Price < Minimum Price before sending.

4) Sizing, dynamic height, and lazy‑load (snippets + QA)

Prevent layout jank and wasted bandwidth.

Recommended defaults

  • Explicit height: start high, tune down after QA; bump +200px for mobile breakpoints.
  • Lazy‑load: add loading="lazy" to all offscreen iframes.
  • Container: cap width to ~940–1100px for readability; width="100%" on the iframe.

Performance‑first wrapper

<section style="max-width: 1040px; margin: 0 auto;">
  <h2 style="font: 700 20px/1.3 system-ui; margin: 0 0 12px;">Configure your package</h2>
  <div style="position: relative;">
    <!-- Reserve space to avoid CLS -->
    <div style="height: 1000px; width: 100%;"></div>
    <iframe
      class="airtable-embed airtable-dynamic-height"
      title="Pricing Configurator"
      src="https://airtable.com/embed/shrXXXXXXXXXXXXXX?backgroundColor=teal"
      frameborder="0"
      width="100%"
      height="1000"
      loading="lazy"
      style="position: absolute; inset: 0; background: #fff; border: 1px solid #e5e7eb; border-radius: 8px;">
    </iframe>
    <script async src="https://static.airtable.com/js/embed/embed_snippet_v1.js"></script>
  </div>
</section>

QA checklist

  • No nested scroll at common heights (iPhone 12/14, Pixel 7/8, 1366×768, 1440×900).
  • No layout shift when the iframe loads (watch Core Web Vitals).
  • Test below‑the‑fold embeds still load on scroll (lazy‑load working).

5) Mobile and host gotchas (fixes + tests)

Design around these predictable issues.

Mobile quirks (embedded Views)

  • Toolbar/search may be hidden on small screens. If users must filter/search, prefer Forms or link to a full‑page view.
  • Increase iframe height to avoid nested scroll; keep airtable-dynamic-height.

Host/iframe blocking

  • Some hosts (e.g., WordPress.com plans) or site CSP/X‑Frame‑Options policies block third‑party iframes.
  • Quick test: open DevTools → Console. If you see messages like “Refused to display in a frame… X‑Frame‑Options” or “CSP ‘frame‑ancestors’…”, your host is blocking embeds.
  • If blocked, use one of the Google fallbacks below or link out to a dedicated calculator page.

Data exposure

  • Never embed an entire base that contains sensitive data. Create a dedicated view with only the fields you intend to expose.
  • For Interfaces, review page‑level access settings before sharing.

6) Google Sheets fallback: Publish to web (view‑only)

Use this for a read‑only catalog, price table, or explainer when iframes from Airtable are blocked.

Steps

  1. In Google Sheets: File → Share → Publish to web → Embed.
  2. Choose the specific Sheet (or range) that’s safe to show.
  3. Copy the iframe and paste into your page wrapper.

Example

<div style="max-width: 960px; margin: 0 auto;">
  <iframe
    src="https://docs.google.com/spreadsheets/d/e/2PACX-.../pubhtml?widget=true&headers=false"
    width="100%"
    height="820"
    frameborder="0"
    loading="lazy"
    style="background:#fff; border:1px solid #e5e7eb; border-radius:8px;">
  </iframe>
</div>

Notes

  • Published embeds are view‑only. Use the Apps Script Web App (next section) for interactive quotes.
  • Treat the published Sheet as public; don’t include PII or internal costs you wouldn’t post on your site.

7) Google Apps Script Web App: interactive fallback (starter code)

Deploy an interactive, embeddable configurator that reads a pricing sheet, enforces margin, and emails a PDF quote.

What this starter does

  • Renders a responsive form UI (embedded or standalone).
  • Reads prices from a "Pricing" sheet in your Google Spreadsheet.
  • Computes total, checks a minimum margin from Settings!B2, and blocks/adjusts if below threshold.
  • Emails the client an HTML summary with a PDF quote attached.

Project setup (once)

  1. Create a Google Spreadsheet with:
    • Sheet "Pricing" columns: Item | UnitPrice | Cost (numbers). One row per option.
    • Sheet "Settings": put your minimum margin (e.g., 0.6 for 60%) in cell B2.
  2. Extensions → Apps Script. Add two files: Code.gs and index.html.
  3. Deploy → New deployment → Type: Web app → Execute as: Me → Who has access: Anyone with the link. Copy the Web App URL.

Code: Code.gs

const CONFIG = {
  SPREADSHEET_ID: 'REPLACE_WITH_YOUR_SHEET_ID',
  PRICING_SHEET: 'Pricing',
  SETTINGS_RANGE: 'Settings!B2', // minimum margin, e.g., 0.6
  BRAND_FROM: 'quotes@yourdomain.com' // optional; fallback is your account
};

function doGet() {
  return HtmlService.createTemplateFromFile('index')
    .evaluate()
    .setTitle('Quote Configurator')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}

function getPricing() {
  const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
  const sh = ss.getSheetByName(CONFIG.PRICING_SHEET);
  const rows = sh.getDataRange().getValues();
  const [header, ...data] = rows;
  const idx = Object.fromEntries(header.map((h,i)=>[h,i]));
  return data.filter(r => r[idx.Item]).map(r => ({
    item:  String(r[idx.Item]),
    unit:  Number(r[idx.UnitPrice] || 0),
    cost:  Number(r[idx.Cost] || 0)
  }));
}

function submitQuote(payload) {
  // payload = { customer:{name,email}, selections:[{item, qty}] }
  const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
  const minMargin = Number(ss.getRange(CONFIG.SETTINGS_RANGE).getValue() || 0.6);

  // Build price map
  const priceMap = {};
  getPricing().forEach(p => priceMap[p.item] = p);

  // Compute totals
  let subtotal = 0, costTotal = 0;
  const lines = payload.selections.map(sel => {
    const p = priceMap[sel.item];
    if (!p) throw new Error('Unknown item: ' + sel.item);
    const qty = Number(sel.qty || 0);
    const linePrice = p.unit * qty;
    const lineCost  = p.cost * qty;
    subtotal  += linePrice;
    costTotal += lineCost;
    return { item: sel.item, qty, unit: p.unit, linePrice, lineCost };
  });

  // Margin guardrail
  const margin = subtotal > 0 ? 1 - (costTotal / subtotal) : 0;
  let adjusted = false;
  let finalTotal = subtotal;
  if (margin < minMargin && subtotal > 0) {
    finalTotal = Math.ceil(costTotal / (1 - minMargin));
    adjusted = true;
  }

  // Create PDF via Google Docs (simple template)
  const doc = DocumentApp.create(`Quote - ${payload.customer.name} - ${new Date().toISOString()}`);
  const body = doc.getBody();
  body.appendParagraph('Quote').setHeading(DocumentApp.ParagraphHeading.HEADING1);
  body.appendParagraph(`Client: ${payload.customer.name} <${payload.customer.email}>`);
  body.appendParagraph(' ');
  const table = body.appendTable([['Item','Qty','Unit','Line Total']]);
  lines.forEach(l => table.appendTableRow([l.item, String(l.qty), formatUSD(l.unit), formatUSD(l.linePrice)]));
  body.appendParagraph(' ');
  body.appendParagraph(`Estimated Cost: ${formatUSD(costTotal)}`);
  body.appendParagraph(`Margin Target: ${(minMargin*100).toFixed(0)}%`);
  body.appendParagraph(`Subtotal: ${formatUSD(subtotal)}`);
  if (adjusted) body.appendParagraph('Adjusted to meet margin target.').setBold(true);
  body.appendParagraph(`Quote Total: ${formatUSD(finalTotal)}`).setBold(true);
  doc.saveAndClose();

  const pdfBlob = DriveApp.getFileById(doc.getId()).getAs('application/pdf').setName('Quote.pdf');
  const htmlSummary = renderHtmlSummary(payload.customer, lines, costTotal, subtotal, finalTotal, minMargin, adjusted);

  GmailApp.sendEmail(
    payload.customer.email,
    'Your Quote',
    'Please view this quote as HTML or the attached PDF.',
    { name: 'Quotes', htmlBody: htmlSummary, attachments: [pdfBlob], replyTo: CONFIG.BRAND_FROM }
  );

  // Cleanup temp Doc to avoid clutter
  DriveApp.getFileById(doc.getId()).setTrashed(true);

  return { ok: true, total: finalTotal, adjusted, margin: margin };
}

function formatUSD(n){ return Utilities.formatString('$%,.2f', n || 0); }

function renderHtmlSummary(customer, lines, cost, sub, total, minMargin, adjusted){
  const rows = lines.map(l => `
    <tr>
      <td>${l.item}</td>
      <td style="text-align:right">${l.qty}</td>
      <td style="text-align:right">${formatUSD(l.unit)}</td>
      <td style="text-align:right">${formatUSD(l.linePrice)}</td>
    </tr>`).join('');
  return `
  <div style="font:14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color:#0D1321;">
    <h2 style="margin:0 0 8px;">Quote</h2>
    <p style="margin:0 0 12px;">Client: ${customer.name} <${customer.email}></p>
    <table width="100%" cellspacing="0" cellpadding="6" style="border-collapse:collapse;">
      <thead>
        <tr style="background:#F5F7FA"><th align="left">Item</th><th align="right">Qty</th><th align="right">Unit</th><th align="right">Line Total</th></tr>
      </thead>
      <tbody>${rows}</tbody>
    </table>
    <p>Estimated Cost: <strong>${formatUSD(cost)}</strong></p>
    <p>Margin Target: <strong>${(minMargin*100).toFixed(0)}%</strong></p>
    ${adjusted ? '<p style="color:#B45309"><strong>Adjusted to meet margin target.</strong></p>' : ''}
    <p style="font-size:16px">Quote Total: <strong>${formatUSD(total)}</strong></p>
  </div>`;
}

Code: index.html

<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <style>
      body{font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif; color:#0D1321; margin:16px;}
      .card{max-width:940px;margin:0 auto;padding:16px;border:1px solid #e5e7eb;border-radius:10px;background:#fff}
      .grid{display:grid;grid-template-columns:1fr auto auto;gap:8px;align-items:center}
      .btn{background:#2FD4EA;border:0;padding:10px 14px;border-radius:8px;color:#0D1321;font-weight:700;cursor:pointer}
      .btn:disabled{opacity:.6;cursor:not-allowed}
      label{font-weight:600}
      input,select{padding:8px;border:1px solid #e5e7eb;border-radius:8px;width:100%}
      table{width:100%;border-collapse:collapse;margin-top:12px}
      th,td{padding:8px;border-bottom:1px solid #f0f2f5}
      th{text-align:left;background:#f8fafc}
    </style>
  </head>
  <body>
    <div class="card">
      <h2>Configure your quote</h2>
      <div id="items"></div>
      <div style="margin:12px 0;">
        <label>Name <input id="cname" required></label>
        <label style="margin-left:8px;">Email <input id="cemail" type="email" required></label>
      </div>
      <button id="submit" class="btn">Email me the quote PDF</button>
      <div id="status" style="margin-top:10px"></div>
    </div>

    <script>
      const itemsDiv = document.getElementById('items');
      const submitBtn = document.getElementById('submit');
      const statusDiv = document.getElementById('status');

      google.script.run.withSuccessHandler(renderItems).getPricing();

      function renderItems(pricing){
        const rows = pricing.map(p=>`
          <div class="grid">
            <label>${p.item} <small>(${fmt(p.unit)})</small></label>
            <input type="number" min="0" step="1" value="0" data-item="${p.item}">
            <div></div>
          </div>
        `).join('');
        itemsDiv.innerHTML = rows;
      }

      submitBtn.addEventListener('click', ()=>{
        const selections = Array.from(itemsDiv.querySelectorAll('input[type=number]'))
          .map(i=>({ item: i.dataset.item, qty: Number(i.value||0) }))
          .filter(x=>x.qty>0);
        const customer = { name: document.getElementById('cname').value.trim(), email: document.getElementById('cemail').value.trim() };
        if(!customer.name || !customer.email){ return alert('Enter your name and email.'); }
        if(selections.length===0){ return alert('Select at least one item.'); }
        statusDiv.textContent = 'Generating…';
        submitBtn.disabled = true;
        google.script.run.withSuccessHandler(res=>{
          statusDiv.textContent = res.ok ? `Sent! Total ${fmt(res.total)}${res.adjusted?' (adjusted to meet margin)':''}` : 'Failed to send.';
          submitBtn.disabled = false;
        }).withFailureHandler(err=>{
          statusDiv.textContent = 'Error: ' + err.message;
          submitBtn.disabled = false;
        }).submitQuote({ customer, selections });
      });

      function fmt(n){ return new Intl.NumberFormat('en-US',{style:'currency',currency:'USD'}).format(n); }
    </script>
  </body>
</html>

Embed it

  • Paste the Web App URL into an iframe on your site (use the same sizing/lazy‑load patterns as Airtable), or link to it in a new tab if your host blocks iframes.

Operational notes

  • Quotas: Gmail/Apps Script have daily send limits; use a workspace account and/or add a transactional email step in your automations for scale.
  • Security: Treat the Web App as public. Validate inputs server‑side (the sample does basic validation). Keep sensitive cost sheets private—only expose computed outputs.

8) Handoff to proposal generator (automation map)

Wire the configurator to your send step so every quote is consistent and margin‑checked.

Airtable path

  • Form submit → Automation: check Price >= Minimum Price → create Quote record → generate PDF (Scripting app, Make, or Zapier) → email + attach → handoff to Proposal (e.g., PandaDoc/DocuSign) with line items.

Apps Script path

  • Web App submitQuote already computes/guards and emails. Optionally: also write a Quote row to a Sheet and trigger Make/Zapier from there for proposal creation and CRM logging.

QA before going live

  • 10 runs with boundary cases: $0, single item, many items, near‑threshold, forced adjustment.
  • Mobile pass on iPhone/Android. No nested scroll. No blocked iframe on prod host.
  • Email deliverability checks (SPF/DKIM, reply‑to set).