Push page values into form fields
One script, one config block. It reads the values you point it at — the boat title and price on a detail page, a product name, a listing ID — and writes each one into a form field, adding the hidden input when the form doesn't have one. The lead lands in the inbox already saying which boat it was about. Site Inspector can generate the whole tag for you from the fields you capture.
Capture the selectors with the Fields tab
Open Site Inspector on the detail page, go to Fields, and point at the elements you want: name the field (Boat Title), click the heading, and the panel captures a stable CSS selector for it. Do the same for the price. Product pages often need nothing at all — the tab auto-detects name, price, SKU, brand, and image from JSON-LD and microdata, which the script can read directly.
Every field you capture also generates a paste-ready Custom JS variable for GTM. You can feed the script either the raw selector or that variable — step 3 covers both.
Shortcut: with your fields checked, choose GTM Custom HTML — fill form fields from the Generate dropdown. The panel hands back this whole tag with the FIELDS block already filled in, plus the field map from step 4 — paste it and skip to naming your form fields.
Add the script
Paste it into a Custom HTML tag in GTM, firing on the pages that have the values (a detail-page trigger, or All Pages — it does nothing on a page where nothing matches), or into the site's <head>. Order doesn't matter: it waits for the form and for the values.
<!-- MTK Page Values -> Form Fields -->
<script>
(function () {
/* ========================== CONFIG ==========================
One entry per value you want to carry onto the lead. Each entry is
addressable three ways, the same as an MTK Attribution field:
Name 'Product Title' -> the entry's label
HTML Name 'product_title' -> the entry's name
Token '[field:product_title]' -> derived from the name
A field gets filled when ANY of the three matches: the input's name,
its visible label, or a [field:name] token sitting in its default
value. The token is the way in when the platform hard-assigns the
input name (Gravity Forms input_7, Webflow, HubSpot).
name the form field to fill - also the dataLayer key, and the
name inside the token
label optional - the readable name, and what a field's visible
label/title is matched against
target optional - a CSS selector for the field, when name/label
won't do it (searched inside each form)
Give each entry ONE source:
selector 'h1.product-title' read it off the page
meta 'og:title' read a meta tag
jsonld 'offers.price' read the page's JSON-LD product data
query 'product_id' read a URL query parameter
path 2 Nth URL path segment (-1 = last)
value a literal, a function, or a GTM variable reference
pasted in unquoted (see the guide for that variant)
Optional per entry:
read 'text' (default) | 'value' | 'html' | 'attr:href'
type 'string' (default) | 'currency' | 'number' | 'phone'
| 'email' | 'raw'
format for phone: 'e164us' (default) | 'e164' | 'digits'
| 'national'; for currency: 'numeric' (default) | 'keep'
- the same choices the Inspector offers per field
fallback value to use when nothing is found
maxLength truncate to this many characters
create false = never add a hidden input for this one
overwrite true = fill even a visible field the person already typed in
The example below is a detail page: the item's title and price onto
the form on it. Replace the names and selectors with your own - or
let the Inspector's Fields tab write this block for you.
*/
/* FIELDS-START */
var FIELDS = [
{
name: 'product_title',
label: 'Product Title',
selector: 'h1.product-title',
type: 'string'
},
{
name: 'product_price',
label: 'Product Price',
selector: '.product-price',
type: 'currency'
},
{
name: 'page_url',
label: 'Page URL',
value: function () { return location.href; }
}
];
/* FIELDS-END */
var OPTIONS = {
forms: 'form', // which forms to fill
createMissing: true, // add a hidden input when the form lacks one
overwriteVisible: false, // never clobber something a person typed
pushToDataLayer: true, // also push the values for GTM to read
eventName: 'mtk_page_values',
tokenPrefix: 'field', // fills any value holding [field:name]
stripUnresolved: true, // blank leftover tokens at submit, so no lead
// ever arrives reading "[field:product_price]"
watchFor: 15000, // keep re-checking this long (ms) after load
debug: false // true = log what it read and filled
};
/* ======================== END CONFIG ======================== */
if (window.__mtkPageToForm) return;
window.__mtkPageToForm = true;
var TAG = '[MTK page->form]';
function log() {
if (!OPTIONS.debug || !window.console) return;
console.log.apply(console, [TAG].concat([].slice.call(arguments)));
}
function isArray(v) {
return Object.prototype.toString.call(v) === '[object Array]';
}
function clean(s) {
return String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
}
function queryAll(root, sel) {
try {
return [].slice.call(
root.querySelectorAll(String(sel).replace(/#([\w-]+)/g, '[id="$1"]'))
);
} catch (e) {
log('bad selector:', sel, e);
return [];
}
}
function readFrom(el, read) {
if (read === 'value') return el.value != null ? el.value : '';
if (read === 'html') return el.innerHTML || '';
if (read && read.indexOf('attr:') === 0) {
return el.getAttribute(read.slice(5)) || '';
}
return el.textContent || '';
}
function visible(el) {
return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
}
function fromSelector(f) {
var els = queryAll(document, f.selector), best = '';
for (var i = 0; i < els.length; i++) {
var v = clean(readFrom(els[i], f.read));
if (!v) continue;
if (visible(els[i])) return v;
if (!best) best = v;
}
return best;
}
var ldCache = null, ldUrl = '';
function ldNodes() {
if (ldCache && ldUrl === location.href) return ldCache;
var out = [];
function walk(d) {
if (!d) return;
if (isArray(d)) {
for (var j = 0; j < d.length; j++) walk(d[j]);
return;
}
if (typeof d !== 'object') return;
out.push(d);
if (d['@graph']) walk(d['@graph']);
}
var blocks = document.querySelectorAll('script[type="application/ld+json"]');
for (var i = 0; i < blocks.length; i++) {
try { walk(JSON.parse(blocks[i].textContent)); } catch (e) { }
}
ldCache = out; ldUrl = location.href;
return out;
}
function dig(node, parts) {
var cur = node;
for (var i = 0; i < parts.length; i++) {
if (isArray(cur)) cur = cur[0];
if (cur == null || typeof cur !== 'object') return '';
cur = cur[parts[i]];
}
if (isArray(cur)) cur = cur[0];
return typeof cur === 'string' || typeof cur === 'number' ? String(cur) : '';
}
function fromJsonLd(path) {
var parts = String(path).split('.'), nodes = ldNodes();
for (var i = 0; i < nodes.length; i++) {
var v = dig(nodes[i], parts);
if (v) return v;
}
return '';
}
function fromMeta(key) {
var el = document.querySelector(
'meta[property="' + key + '"], meta[name="' + key + '"], meta[itemprop="' + key + '"]'
);
return el ? el.getAttribute('content') || '' : '';
}
function fromQuery(key) {
var pairs = location.search.replace(/^\?/, '').split('&');
for (var i = 0; i < pairs.length; i++) {
var kv = pairs[i].split('=');
if (decodeURIComponent(kv[0]) === key) {
return decodeURIComponent((kv[1] || '').replace(/\+/g, ' '));
}
}
return '';
}
function fromPath(n) {
var segs = location.pathname.split('/').filter(Boolean);
var i = n < 0 ? segs.length + n : n - 1;
return segs[i] ? decodeURIComponent(segs[i]) : '';
}
function toNumber(v) {
var m = v.replace(/[^\d.,-]/g, '');
var comma = m.lastIndexOf(','), dot = m.lastIndexOf('.');
if (comma > dot && /,\d{1,2}$/.test(m)) {
m = m.replace(/\./g, '').replace(/,/, '.');
} else {
m = m.replace(/,/g, '');
}
var n = parseFloat(m);
return isNaN(n) ? '' : String(n);
}
function toPhone(v, format) {
var d = v.replace(/[^\d]/g, '');
if (format === 'digits') return d;
if (format === 'e164') return (v.charAt(0) === '+' ? '+' : '') + d;
if (format === 'national') {
var last = d.slice(-10);
if (last.length !== 10) return v;
return '(' + last.slice(0, 3) + ') ' + last.slice(3, 6) + '-' + last.slice(6);
}
if (v.charAt(0) === '+') return '+' + d;
if (d.length === 10) return '+1' + d;
if (d.length === 11 && d.charAt(0) === '1') return '+' + d;
return d;
}
function normalize(raw, type, format) {
var v = clean(raw);
if (!v || type === 'raw') return v;
if (type === 'currency') return format === 'keep' ? v : toNumber(v);
if (type === 'number') return toNumber(v);
if (type === 'email') return v.toLowerCase();
if (type === 'phone') return toPhone(v, format || 'e164us');
return v;
}
function resolveOne(f) {
var v = '';
if (typeof f.value === 'function') {
try { v = f.value(); } catch (e) { log('value() threw for', f.name, e); }
} else if (f.value != null && f.value !== '') {
v = String(f.value);
if (v === 'undefined' || v === 'null') v = '';
if (v.charAt(0) === '{' && v.slice(-2) === '}}') v = '';
} else if (f.selector) v = fromSelector(f);
else if (f.jsonld) v = fromJsonLd(f.jsonld);
else if (f.meta) v = fromMeta(f.meta);
else if (f.query) v = fromQuery(f.query);
else if (f.path != null) v = fromPath(f.path);
v = normalize(v, f.type, f.format);
if (!v && f.fallback != null) v = normalize(f.fallback, f.type, f.format);
if (f.maxLength && v.length > f.maxLength) v = v.slice(0, f.maxLength);
return v;
}
function resolveAll() {
var out = {};
for (var i = 0; i < FIELDS.length; i++) {
out[FIELDS[i].name] = resolveOne(FIELDS[i]);
}
return out;
}
function documents() {
var list = [document];
var frames = document.querySelectorAll('iframe');
for (var i = 0; i < frames.length; i++) {
try {
var d = frames[i].contentDocument;
if (d && d.body) list.push(d);
} catch (e) { }
}
return list;
}
function labelOf(el) {
var l = el.id && el.ownerDocument.querySelector('label[for="' + el.id + '"]');
return clean(
(l && l.textContent) ||
el.getAttribute('aria-label') ||
el.getAttribute('placeholder') || ''
);
}
function targetsIn(form, f) {
var out = f.target ? queryAll(form, f.target) : [];
if (!out.length) out = queryAll(form, '[name="' + f.name + '"]');
if (!out.length && f.label) {
var all = form.querySelectorAll('input, textarea, select');
for (var i = 0; i < all.length; i++) {
if (labelOf(all[i]) === f.label) out.push(all[i]);
}
}
return out;
}
function ensureField(form, f) {
var found = targetsIn(form, f);
if (!found.length && form.querySelector('[data-mtk-token]')) {
var tok = '[' + OPTIONS.tokenPrefix + ':' + f.name + ']';
var carriers = form.querySelectorAll('[data-mtk-token]');
for (var c = 0; c < carriers.length; c++) {
if ((carriers[c].getAttribute('data-mtk-token') || '').indexOf(tok) !== -1) return [];
}
}
if (found.length || !OPTIONS.createMissing || f.create === false) return found;
var input = form.ownerDocument.createElement('input');
input.type = 'hidden';
input.name = f.name;
input.setAttribute('data-mtk-field', f.name);
form.appendChild(input);
log('added hidden input', f.name, 'to', form);
return [input];
}
function setValue(el, value) {
if (el.value === value) return false;
var win = el.ownerDocument.defaultView || window;
var ctor = el.tagName === 'TEXTAREA' ? win.HTMLTextAreaElement
: el.tagName === 'SELECT' ? win.HTMLSelectElement
: win.HTMLInputElement;
var desc = ctor && Object.getOwnPropertyDescriptor(ctor.prototype, 'value');
if (desc && desc.set) desc.set.call(el, value); else el.value = value;
if (el._valueTracker) el._valueTracker.setValue(value === '' ? '\u0000' : '');
el.dispatchEvent(new win.Event('input', { bubbles: true }));
el.dispatchEvent(new win.Event('change', { bubbles: true }));
return true;
}
var lastPushed = '';
function pushValues(values) {
if (!OPTIONS.pushToDataLayer) return;
var any = false;
for (var k in values) if (values[k]) any = true;
if (!any) return;
var sig = JSON.stringify(values);
if (sig === lastPushed) return;
lastPushed = sig;
var payload = { event: OPTIONS.eventName };
for (var k2 in values) payload[k2] = values[k2];
window.dataLayer = window.dataLayer || [];
window.dataLayer.push(payload);
log('dataLayer push', payload);
}
function renderTokens(tpl, values, strip) {
var out = tpl;
for (var name in values) {
var t = '[' + OPTIONS.tokenPrefix + ':' + name + ']';
if (out.indexOf(t) === -1) continue;
var v = values[name];
if (!v && !strip) continue;
out = out.split(t).join(v || '');
}
return out;
}
function hasToken(v) {
return !!v && v.indexOf('[' + OPTIONS.tokenPrefix + ':') !== -1;
}
function fillTokens(form, values, isFinal) {
var els = form.querySelectorAll('input, textarea, select'), n = 0;
for (var i = 0; i < els.length; i++) {
var el = els[i];
var tpl = el.getAttribute('data-mtk-token');
if (!tpl) {
if (!hasToken(el.value)) continue;
tpl = el.value;
el.setAttribute('data-mtk-token', tpl);
}
var strip = isFinal && OPTIONS.stripUnresolved;
if (setValue(el, renderTokens(tpl, values, strip))) n++;
}
return n;
}
function fill(reason, isFinal) {
var values = resolveAll(), touched = 0;
var docs = documents();
for (var d = 0; d < docs.length; d++) {
var forms;
try { forms = [].slice.call(docs[d].querySelectorAll(OPTIONS.forms)); }
catch (e) { forms = []; }
for (var i = 0; i < forms.length; i++) {
touched += fillTokens(forms[i], values, isFinal);
for (var j = 0; j < FIELDS.length; j++) {
var f = FIELDS[j], v = values[f.name];
if (!v) continue;
var els = ensureField(forms[i], f);
for (var k = 0; k < els.length; k++) {
var el = els[k];
var ours = el.type === 'hidden' || el.hasAttribute('data-mtk-field');
if (!ours && el.value && !(f.overwrite || OPTIONS.overwriteVisible)) continue;
if (setValue(el, v)) touched++;
}
}
}
}
pushValues(values);
if (touched) log(reason + ':', 'filled', touched, 'field(s)', values);
return values;
}
var pending = null;
function schedule(reason) {
if (pending) return;
pending = setTimeout(function () { pending = null; fill(reason); }, 60);
}
function start() {
fill('load');
if (window.MutationObserver) {
new MutationObserver(function () { schedule('mutation'); })
.observe(document.documentElement, { childList: true, subtree: true });
}
var until = new Date().getTime() + OPTIONS.watchFor;
(function tick() {
if (new Date().getTime() > until) return;
fill('retry');
setTimeout(tick, 500);
})();
}
document.addEventListener('submit', function () { fill('submit', true); }, true);
function onRoute() {
ldCache = null;
lastPushed = '';
setTimeout(function () { fill('route'); }, 300);
}
['pushState', 'replaceState'].forEach(function (m) {
var orig = window.history && window.history[m];
if (typeof orig !== 'function') return;
window.history[m] = function () {
var r = orig.apply(this, arguments);
onRoute();
return r;
};
});
window.addEventListener('popstate', onRoute);
window.mtkPageValues = function () { return resolveAll(); };
window.mtkFillForms = function () { return fill('manual'); };
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
})();
</script>
<!-- End MTK Page Values -> Form Fields -->Edit the FIELDS list — that's the whole job
Everything you change lives in the FIELDS array at the top. One entry per value: name is the form field it fills, and one source tells it where to read from — selector, jsonld, meta, query, path, or a literal value. The shipped example is a generic detail page — title by selector, price by selector normalized to a number, and the page URL — so rename the entries to match what your pages actually hold (boat_title, unit_number, package_name).
Already built the values as Custom JS variables in GTM? Reference them in value instead and GTM resolves them before the tag runs — the script never touches the page itself. Note the variable goes in unquoted, exactly as GTM inserts it — and only ever in the config, never in a comment: GTM substitutes a double-brace reference anywhere in the tag body, comments included, and errors on one it can't resolve.
var FIELDS = [
{
name: 'product_title',
label: 'Product Title',
value: {{cJS - Product Title}}
},
{
name: 'product_price',
label: 'Product Price',
value: {{cJS - Product Price}},
type: 'currency'
},
{
name: 'page_url',
label: 'Page URL',
value: {{Page URL}}
}
];Useful knobs on an entry: type: 'currency' strips $ and commas so the value works as a conversion value ($389,500.00 → 389500); labelmatches a field by its visible label when the builder won't let you set a field name (Wix, some page builders); maxLengthkeeps a long title inside a platform's character limit; fallback fills in a default when nothing is found.
Where the values land
A · Form fields. For each form on the page, the script fills the input whose namematches the entry — and creates a hidden one when it's missing, so a hand-built form needs no markup changes. Declaring them yourself is still worth it when the platform maps fields ahead of the first submission (Gravity Forms, HubSpot, a CRM):
<!-- A: by HTML Name - the input's name matches the entry's name. -->
<input type="hidden" name="product_title" />
<input type="hidden" name="product_price" />
<input type="hidden" name="page_url" />
<!-- B: by token - for a platform that assigns the input name itself
(Gravity Forms input_7, Webflow, HubSpot). Put the token in the
field's Default Value; the name is then free to read nicely in
the submission ("Product Title: 2019 Sea Ray SLX 400"). -->
<input type="hidden" name="Product Title" value="[field:product_title]" />
<input type="hidden" name="Product Price" value="[field:product_price]" />
<input type="hidden" name="Page URL" value="[field:page_url]" />A visible field the person already typed in is never overwritten — only empty and hidden fields get written, unless you set overwrite: true on the entry.
Each entry is addressable three ways — the same model as an MTK Attribution field — because no single one works on every platform:
| Name | HTML Name | Token |
|---|---|---|
| Product Title | product_title | [field:product_title] |
| Product Price | product_price | [field:product_price] |
Name is the entry's label — used when the builder only lets you set a visible field title (Wix). HTML Name is the entry's name— the input's name attribute, the usual case. Token goes in a field's default value and is filled by value — the way in when the platform assigns the input name itself (Gravity Forms input_7, Webflow, HubSpot), and it lets the field keep a name that reads well in the submission. Any one of the three is enough; a token can even sit inside a longer string ([field:product_title] — [field:product_price]) to build a summary line. Unresolved tokens are blanked at submit, so a lead never arrives reading [field:product_price].
B · The dataLayer. The same values are pushed as an mtk_page_values event, so GTM can read them as Data Layer Variables and send them on with a conversion — the boat price as the value parameter on a Meta or GA4 event, for instance.
C · Your own JavaScript. If the form builds its own submit payload, skip the DOM entirely and ask for the values:
// Resolved fresh, at the moment you ask for them.
var page = window.mtkPageValues();
// -> { product_title: '2019 Sea Ray SLX 400', product_price: '389500', page_url: '...' }
// At your confirmed-successful-submit point, fold them into the record:
var lead = Object.assign({ email: emailValue /* ...your fields... */ }, page);
// POST `lead` to your API / CRM.
// Or, if the form is built after the script ran and you want to force a pass:
window.mtkFillForms();Or hand the values to MTK Attribution instead
Running Attribution on the site? There's a second route that skips the filling entirely: push the values to the dataLayer and let Attribution deliver them. In the license config, add one field per value with the data source dl:product_title, then push a matching value — the Inspector's GTM Custom HTML — push to MTK Attribution output writes that tag for you, and the license config shows the same tag once a dl: field exists.
Why bother: Attribution reaches places a page script can't. Its Squarespace and GoHighLevel companions get values into a state-driven form and a cross-origin iframe — the two cases the standalone tag above cannot cover. The values also ride the same tokens, hidden fields, and attribution_summary as everything else on the lead.
One ordering rule: the push has to run before the Attribution tag. The engine resolves its whole field map once and the companions build from that single pass, so a late push means those platforms miss the value. The simplest way is to paste the push directly above the loader line in the same Custom HTML tag — scripts in one tag run in order, so it lands before the engine is even fetched. Tag Sequencing or a higher firing priority works too. One catch: if a variable reads the page (a title out of an <h1>), trigger that tag on DOM Ready rather than All Pages, or it resolves before the element exists and pushes an empty value.
Verify
Set debug: true in OPTIONS, load a detail page, and the console logs what it read and what it filled. Then submit a test lead and confirm the values arrive where submissions land. In GTM, Preview mode shows the mtk_page_values event with every value on it.
Nothing filled? Nine times out of ten the selector matched nothing on that page — templates differ between listing types. Re-capture it with the Fields tab on the page that fails, or give the entry a comma-separated selector covering both templates.