GTM Form Engagement & Abandonment

Form Engagement Waterfall in GTM — Visibility, Field, Submit, and Response

GTM Form Engagement Waterfall: Visibility, Field, Submit & Response

Four Google Analytics 4 events, one shared event name, and a single contact_step_label parameter — enough to build a true form-abandonment funnel from nothing but Google Tag Manager triggers.

Published · Tags: GTM, Google Analytics 4, Form Tracking, Form Abandonment, Element Visibility, dataLayer, Funnel Exploration, GA4

Standing on the shoulders of giants. This recipe is a direct continuation of two earlier posts on this blog. The field-engagement stage below is built entirely on the generic document.addEventListener pattern that Simo Ahava first published in 2013 and that I adapted for GA4 in Listen to Any Browser Event in GTM. The element-identification logic — the coalesce chains that walk up the DOM to find a usable class, id, or label — comes straight from GTM Universal Click – No JavaScript. Neither of those posts touched forms directly. This one bolts both techniques onto a real Blogger contact form widget and adds two more stages — visibility and response — to turn them into a complete waterfall.
Most GTM form recipes stop at one event: gtm.formSubmit. That tells you a form was submitted, but it tells you nothing about the funnel that led there — how many visitors even saw the form, which field they touched last before giving up, or whether the submission actually succeeded once it reached the server. A single submit event is a snapshot. A waterfall is a story. This recipe sends four GA4 events, in page order, all sharing one event name — contact_form_step — so they stack cleanly into a GA4 Funnel Exploration:
  1. Visible — the contact form widget scrolls into view
  2. Field — a visitor changes a field's value (Simo's 2013 pattern, adapted for forms)
  3. Submit — the visitor clicks the Send button
  4. Response — the page shows a success or error message
Compare step 1 against step 3 and you get a scroll-to-submit rate. Compare step 2 against step 3 and you learn whether visitors who engage with a field actually finish. Compare step 3 against step 4 and you catch silent submission failures — the gap that almost no analytics setup measures, because it depends on which DOM element the form's own JavaScript reveals after the request resolves. The illustration form for this recipe is Blogger's own built-in contact form widget — the same three-field Name / Email* / Message* form running in this blog's sidebar.

What's Inside the Container

The exported container groups the recipe into two folders: Analytics (the shared GA4 configuration and environment routing infrastructure covered in earlier posts) and Contact Form (everything specific to the waterfall). A third folder, Element Hierarchy, supplies the coalesce chains reused from the click-tracking recipe, and Custom Events holds the onChange listener carried over from the 2013 pattern.

Asset TypeNamePurpose
Tag (Custom HTML)onChange ListenerAttaches a native change event listener to document; fires on gtm.js load
Tag (GA4 Event)contact form step visibleStage 1 — sends contact_form_step with contact_step_label = "visible" when the form scrolls into view
Tag (GA4 Event)contact form step fieldStage 2 — sends contact_form_step with the changed field's name as the label
Tag (GA4 Event)contact form step submitStage 3 — sends contact_form_step with contact_step_label = "submit" on Send click
Tag (GA4 Event)contact form step responseStage 4 — sends contact_form_step with a dynamic "success" or "error" label
Tag (GA4 Event)contact form conversion metricFires alongside stage 4 — a separate, discretely named conversion event for GA4's Conversions list
Tag (GA4 Event)onChange Event PushLegacy tag from the original 2013-pattern recipe; left in the container but paused, superseded by "contact form step field"
Tag (Google Tag)Google Analytics ConfigurationStandard GA4 config tag with environment-aware Stream ID routing
Trigger (Element Visibility)contact-form-widgetdiv[class="contact-form-widget"] — fires stage 1
Trigger (Custom Event)gtm.js changeMatches gtm.change pushed by the onChange listener — fires stage 2
Trigger (Click)contact-form-button-submitMatches the Send button's Click ID and Click Classes — fires stage 3
Trigger (Element Visibility)contact-form-success-message-with-borderp[class="contact-form-success-message-with-border"] — fires stage 4 (success branch)
Trigger (Element Visibility)contact-form-error-message-with-borderp[class="contact-form-error-message-with-border"] — fires stage 4 (error branch)
Trigger (Custom Event)gtm.js loadFires the onChange Listener Custom HTML tag when GTM initializes
Variable (Custom JS)generic event handlerSimo Ahava's 2013 callback pattern — pushes element metadata to the dataLayer on any native browser event
Variable (Custom Template)coalesce className / coalesce idIf Else If lookups — return the first non-empty class or id walking up the DOM tree
Variable (Custom Template)Coalesce Click ElementsReturns the first human-readable label: Click Text → Title → Name → Value
Variable (Switch Lookup)contact form step conversion statusMaps the visible message's Form ID to the literal string "success" or "error"
Variable (Switch Lookup)contact form conversion metric nameMaps the same Form ID to a distinct GA4 event name for the conversion tag
Variable (Auto-Event)Form ID / Form Text / Form ClassesBuilt-in GTM variable types, repurposed here to read visibility-trigger context (see below)
Custom TemplateIf Else If – Advanced Lookup TablePowers every coalesce variable (by Sublimetrix)

Architecture Overview: The Four-Stage Waterfall

Every stage shares the same GA4 event name, contact_form_step, and the same four parameters: contact_step_class, contact_step_name, contact_step_id, and contact_step_label. Only the trigger and the value of contact_step_label change from stage to stage. That single design decision is what makes the waterfall possible — GA4's Funnel Exploration can group all four stages under one event and let contact_step_label define the funnel steps in page order.

  1. Widget enters the viewport. The contact-form-widget Element Visibility trigger fires the first time the form's container is 100% on screen. The contact form step visible tag sends contact_form_step with contact_step_label = "visible".
  2. A visitor changes a field. The onChange Listener Custom HTML tag — registered once at gtm.js load — has already attached a native change listener to document. When a visitor commits a value in Name, Email, or Message, the generic event handler variable pushes a gtm.change event carrying the changed element's id, class, and name. The gtm.js change Custom Event trigger catches it and fires contact form step field, which sends contact_form_step with contact_step_label set to the field's own name.
  3. The visitor clicks Send. The contact-form-button-submit Click trigger matches the exact Click ID and Click Classes of Blogger's submit button. The contact form step submit tag sends contact_form_step with contact_step_label = "submit".
  4. The form responds. Blogger's own form-handling script reveals one of two hidden <p> elements once the submission resolves — a success message or an error message. Two Element Visibility triggers watch both elements. Whichever one appears fires two tags at once: contact form step response, which closes out the waterfall with a dynamic "success" or "error" label, and contact form conversion metric, which sends a separate, discretely named GA4 event so the success case can be marked as a Conversion independently of the funnel.

Stage 1 — Form Visibility

The entry point is a standard GTM Element Visibility trigger targeting the form's wrapping <div>:

Trigger SettingValue
Selector typeCSS
Element selectordiv[class="contact-form-widget"]
When to fire this triggerMany per element
Minimum percent visible100%
Observe DOM changesEnabled

Many per element, not once per element: most visibility recipes use "Once per element" so a single impression is logged. This one deliberately uses Many per element, because a visitor who scrolls the form on and off screen repeatedly while deciding whether to fill it out is itself a signal — that behavior is visible in GA4 as multiple visible steps against a single submit, and it's a legitimate hesitation signal worth keeping rather than filtering out.

The contact form step visible tag reads the form's own identifying attributes using the same coalesce variables introduced in the click-tracking recipe — {{coalesce className}}, {{coalesce id}}, and {{Coalesce Click Elements}} — so the event carries a usable label even if Blogger's theme wraps the widget in extra nested containers with no class of their own.

GA4 ParameterGTM Variable
contact_step_class{{coalesce className}}
contact_step_name{{Coalesce Click Elements}}
contact_step_id{{coalesce id}}
contact_step_label"visible" (static string)

Stage 2 — Field Engagement (Simo Ahava's 2013 Pattern, Applied to Forms)

This is the stage that makes the waterfall a waterfall and not just a funnel. Visibility and submit only tell you whether someone saw the form and whether they finished it. Field-level engagement tells you where, between those two moments, a visitor actually stopped.

The mechanism is identical to the one documented in Listen to Any Browser Event in GTM: a Custom HTML tag attaches a native change listener to document once, at gtm.js load, and every field on the page — Name, Email, Message — bubbles its change events up to that single listener automatically.

<script>
  var eventType = 'change';
  if (document.addEventListener) {
    document.addEventListener(eventType, {{generic event handler}}, false);
  }
  else if (document.attachEvent) {
    document.attachEvent('on' + eventType, {{generic event handler}});
  }
</script>

The generic event handler variable is Simo's 2013 callback, unmodified:

function() {
  return function(e) {
    window.dataLayer.push({
      'event':              'gtm.' + e.type,
      'gtm.element':        e.target,
      'gtm.elementClasses': e.target.className || '',
      'gtm.elementId':      e.target.id || '',
      'gtm.elementTarget':  e.target.target || '',
      'gtm.elementUrl':     e.target.href || e.target.action || '',
      'gtm.originalEvent':  e
    });
  }
}

When a visitor tabs out of the Email field having typed something, the browser's native change event fires, the handler pushes gtm.change, and the gtm.js change Custom Event trigger catches it.

The reuse trick: the contact form step field tag doesn't need any new variables to identify which field changed. Because the handler pushes gtm.element, gtm.elementClasses, and gtm.elementId — the exact same dataLayer keys that GTM's built-in Click auto-event variables read — {{Click ID}}, {{Click Classes}}, and {{Element Name}} populate correctly even though no click ever happened. GTM's auto-event variable families don't actually care which trigger fired; they read whatever is sitting in the generic gtm.element* keys at the time the tag evaluates. That's the same principle this container leans on again in stage 4.

GA4 ParameterGTM Variable
contact_step_class{{Click Classes}}
contact_step_name{{Element Name}}
contact_step_id{{Click ID}}
contact_step_label{{Element Name}} — the field's own name attribute

Why contact_step_label repeats contact_step_name here: in a funnel exploration, the label is the axis GA4 groups steps on. Using the field's name — name, email-message, and so on — as the label means each field becomes its own funnel step in the report, letting you see the drop-off between "engaged with Name" and "engaged with Message" directly, rather than collapsing all field interactions into one generic step.

Housekeeping note: the container also ships the original onChange Event Push tag from the 2013-pattern recipe, wired to the same gtm.js change trigger. It's left in place but paused — its onChange event name doesn't fit the shared contact_form_step naming needed for the waterfall, so contact form step field supersedes it for this use case. Unpause it separately if you also want a parallel, form-agnostic onChange event stream.

Stage 3 — Form Submit

The submit stage uses a standard GTM Click trigger with two AND-combined filter conditions, matching Blogger's own submit button precisely:

FilterCondition
{{Click ID}}equals ContactForm1_contact-form-submit
{{Click Classes}}equals contact-form-button contact-form-button-submit

Both conditions together avoid false positives from other buttons on the page that might share one attribute but not the other. The contact form step submit tag then sends the same contact_form_step event with a static label:

GA4 ParameterGTM Variable
contact_step_class{{Click Classes}}
contact_step_name{{Element Name}}
contact_step_id{{Click ID}}
contact_step_label"submit" (static string)

Note what this stage deliberately does not use: GTM's built-in Form Submission trigger. Blogger's contact form widget submits via its own script rather than a native form POST that GTM's form trigger reliably detects, so a Click trigger on the button itself is the dependable signal here. This mirrors the point made in Listen to Any Browser Event in GTM about GTM's built-in triggers not always covering every framework's submission behavior.

Stage 4 — Success or Error Response

This is the stage almost no form-tracking setup includes, and it's the one that turns "form submitted" into "form actually worked." Blogger's widget keeps two message elements in the DOM at all times — one for success, one for error — and reveals whichever one applies only after its own script resolves the submission:

TriggerElement Selector
contact-form-success-message-with-borderp[class="contact-form-success-message-with-border"]
contact-form-error-message-with-borderp[class="contact-form-error-message-with-border"]

Both are Element Visibility triggers at 100% on-screen ratio with DOM-change observation enabled, since the elements only become visible after a script mutates the page — there's no separate page load or click to hang a trigger off. Two tags fire from these triggers:

contact form step response — closes the waterfall

This tag completes the funnel with the same contact_form_step event name used by every earlier stage. Its label isn't static — it's resolved dynamically by a Switch/Lookup Table variable, contact form step conversion status, keyed on which message element became visible:

if Form ID == "ContactForm1_contact-form-success-message" → "success"
if Form ID == "ContactForm1_contact-form-error-message"   → "error"
GA4 ParameterGTM Variable
contact_step_class{{Form Classes}}
contact_step_name{{Form Text}}
contact_step_id{{Form ID}}
contact_step_label{{contact form step conversion status}}"success" or "error"

Why Form ID, Form Text, and Form Classes work here even though no form was submitted: these are GTM's built-in auto-event variables, normally populated by the Form Submission trigger family. But like {{Click ID}} in stage 2, they don't actually require that specific trigger — they read the generic gtm.elementId, gtm.elementText, and gtm.elementClasses dataLayer keys, which GTM's Element Visibility trigger populates on its own every time it fires. Because the visible element in this case is the message <p> tag itself, {{Form ID}} resolves to that paragraph's id, and {{Form Text}} resolves to its visible text — the exact success or error copy Blogger renders. This is the same principle documented in GTM Universal Click – No JavaScript: GTM's auto-event variable families are really just labeled windows onto a shared set of generic dataLayer keys, and any trigger that populates those keys can feed any variable family that reads them.

contact form conversion metric — a discrete conversion event

The waterfall step above is useful for funnel analysis, but GA4's Conversions list works best with a single, unambiguous event name per conversion — not one event with a dynamic label buried in a parameter. So the same trigger pair also fires a second tag that sends a distinct event name, resolved by a companion lookup variable, contact form conversion metric name:

if Form ID == "ContactForm1_contact-form-success-message" → "contact-form-success"
if Form ID == "ContactForm1_contact-form-error-message"   → "contact-form-error"
GA4 ParameterGTM Variable
Event name{{contact form conversion metric name}}
conversion_label{{Form ID}}
conversion_value{{Form Text}}
conversion_name{{contact form conversion metric name}}

Mark contact-form-success as a Conversion in GA4 admin and you have a clean, single-purpose conversion metric — independent of the funnel, and unaffected by how you later restructure the waterfall's step labels.

Full Event Reference

StageEvent Namecontact_step_labelTrigger Type
1. Visiblecontact_form_stepvisibleElement Visibility
2. Fieldcontact_form_stepfield's name attributeCustom Event (gtm.change)
3. Submitcontact_form_stepsubmitClick
4. Responsecontact_form_stepsuccess / errorElement Visibility
4b. Conversioncontact-form-success / contact-form-error— (own event, not a step)Element Visibility (same trigger as 4)

Building the GA4 Funnel Exploration

  1. In GA4, go to Explore → Funnel exploration.
  2. Add a new step for each waterfall stage, all filtered on Event name = contact_form_step.
  3. On each step, add a second condition on the contact_step_label parameter: visible for step 1, your field names for step 2 (add one sub-step per field if you want field-level resolution), submit for step 3, and success for step 4.
  4. Enable "Open funnel" if you want to include visitors who entered mid-funnel (e.g. arrived already scrolled past the form), or leave it closed to require strict step 1 entry.
  5. Add contact_step_id or contact_step_name as a breakdown dimension on the field step to see exactly which fields are engaged most and least before drop-off.

Register contact_step_class, contact_step_name, contact_step_id, and contact_step_label as custom event-scoped dimensions under Admin → Custom definitions → Custom dimensions before they'll appear as report and exploration options.

How to Import

  1. Download the JSON from the Google Drive link.
  2. In GTM go to Admin → Import Container.
  3. Upload gtm-waterfall-form-field-engagement.json.
  4. Choose Merge to preserve your existing configuration.
  5. Update the Measurement Stream ID Development and Measurement Stream ID Production constants with your own GA4 Measurement IDs.
  6. If your form isn't Blogger's contact widget, update the four CSS selectors in the Element Visibility triggers and the Click ID / Click Classes filter on the submit trigger to match your own markup.
  7. In GA4, register contact_step_class, contact_step_name, contact_step_id, and contact_step_label as event-scoped custom dimensions.
  8. Mark contact-form-success as a Conversion under Admin → Events.
  9. Open GTM Preview mode, scroll to the form, change a field, submit it, and confirm all four contact_form_step events fire in order in the Tag Assistant panel — followed by either contact-form-success or contact-form-error.
  10. Publish the container.

What You Can Do With It

  • Build a GA4 Funnel Exploration to see exactly where visitors abandon the form — after seeing it, after starting a field, or after submitting
  • Compare field-level engagement rates to spot a specific field (often Message) that causes disproportionate drop-off
  • Catch silent submission failures that a submit-only setup would report as successful conversions
  • Segment the contact-form-error conversion event by page path to find which landing pages produce the most failed submissions
  • Adapt the same four-stage pattern to any form — checkout, newsletter signup, gated content — by swapping the CSS selectors and Click filter values

The full source — container JSON, all variable code, and the complete event reference — is available via the Google Drive link. This recipe builds directly on the DOM traversal and change-event techniques from the two posts linked above; read those first if any of the coalesce or auto-event variable behavior here is unfamiliar.

Comments

Popular posts from this blog

Site Landing & Site Referrer Preservation

GTM Browser Viewport Measurement

UTM & URL Query String 2 Cookies