Auf dieser Seite
Forms

Forms & analytics

One canonical contact form, built only from the brand component classes — and the analytics wiring that turns it into a measurable funnel. The pattern is lifted from the website's Digital-Check quiz: a tiny track() helper, an ev_<context>_<action> naming convention, a strict no-PII rule, and conversion events that fire only on confirmed success. Full contract & agent coaching → docs/forms-and-analytics.md.

Live form

A standalone page that loads only tokens.css + css/base.css + components.css + utilities.css — exactly what a consumer gets. The analytics run live inside the frame; open it full-screen and watch window.dataLayer in the console as you interact. Source: site/archetypes/contact-form.html — copy it as your starting point.

Open ↗

Markup

Built entirely from existing brand classes — .form-input, .form-textarea, .form-checkbox, .btn-primary, and .form-message (toggled --success/--error). No bespoke styling.

View source
<form id="contact-form" novalidate>
  <label class="form-label" for="cf-name">Name *</label>
  <input class="form-input" id="cf-name" name="name" type="text" autocomplete="name" required placeholder="Vor- und Nachname">

  <label class="form-label" for="cf-email">E-Mail *</label>
  <input class="form-input" id="cf-email" name="email" type="email" autocomplete="email" required placeholder="ihre@email.de">

  <label class="form-label" for="cf-message">Nachricht *</label>
  <textarea class="form-textarea" id="cf-message" name="message" required placeholder="Worum geht es?"></textarea>

  <label class="form-checkbox">
    <input type="checkbox" id="cf-consent" name="consent" required>
    <span>Ich stimme der Verarbeitung zu. <a href="/datenschutz/">Datenschutz</a>.</span>
  </label>

  <button class="btn-primary" type="submit" id="cf-submit">Nachricht senden →</button>
  <div class="form-message" id="cf-message-box" role="status" aria-live="polite" style="display:none;"></div>
</form>

Analytics JS

The part worth copying. One helper pushes flat events to window.dataLayer; GTM (Consent Mode v2) decides downstream whether a tag fires — we never gate here. The full file (with the backend stub + HubSpot example) is in the archetype source.

var FORM_NAME = 'contact';
var FORM_LOCATION = 'archetype';   // e.g. 'footer', 'kontakt-page', 'hero'

function track(eventName, params) {
  try {
    window.dataLayer = window.dataLayer || [];
    var payload = { event: eventName };
    if (params) {
      for (var k in params) {
        if (Object.prototype.hasOwnProperty.call(params, k)) payload[k] = params[k];
      }
    }
    window.dataLayer.push(payload);
  } catch (e) { /* analytics must never break the form */ }
}

// 1. VIEW — form rendered (use an IntersectionObserver for below-the-fold forms).
track('ev_contact_view', { form_name: FORM_NAME, form_location: FORM_LOCATION });

// 2. FIELD-START — first interaction, fired exactly once.
var started = false;
form.addEventListener('focusin', function () {
  if (started) return;
  started = true;
  track('ev_contact_field_start', { form_name: FORM_NAME, form_location: FORM_LOCATION });
});

// 3. SUBMIT (success-gated) — fires only after the backend confirms. No PII.
submitForm(fields)
  .then(function () {
    track('ev_contact_submit', { form_name: FORM_NAME, form_location: FORM_LOCATION });
  })
  // 4. SUBMIT-ERROR — structural error_type only, never the error message.
  .catch(function () {
    track('ev_contact_submit_error', { form_name: FORM_NAME, form_location: FORM_LOCATION, error_type: 'network' });
  });

Event table

The four-event lifecycle. Every payload is flat ({ event, …params }) and carries structural data only.

EventWhen it firesParams
ev_contact_view Form rendered / scrolled into view form_name, form_location
ev_contact_field_start First field focus — fired once form_name, form_location
ev_contact_submit Backend confirmed success (the conversion) form_name, form_location
ev_contact_submit_error Validation failed or backend rejected form_name, form_location, error_type

The no-PII rule

Name, e-mail and message never enter the dataLayer.
They are read into local variables and handed only to your backend (the HubSpot Forms API in production). The dataLayer carries structural data only — form_name, form_location, field_name, error_type. This keeps analytics free of personal data and the consent story clean.

DSGVO checklist — before you ship a new event

The process, generalized. Project-specific GTM wiring (real tag IDs, triggers, CSP) lives in the website's docs/gtm-setup.md — this is the checklist that travels with the pattern.

  • The event carries no PII — only structural params.
  • Its purpose is named in the right consent category (Analyse or Marketing) in the cookie-consent copy, and the cookie table in the Datenschutz page has a row for any storage it sets.
  • If the receiving vendor is non-EU and not DPF-certified, the Drittland transfer is disclosed.
  • The GTM tag has the matching consent requirement ("require additional consent to fire").
  • The CSP allows any new script/endpoint the tag introduces.

Design events for YOUR form

Same skeleton, any form. Walk it in order:

  1. Name the context — one lowercase token (contact, newsletter, booking). Every event is ev_<context>_<action>.
  2. Enumerate the lifecycle_view_field_start_submit_submit_error. Add steps only if the form has them (a multi-step form adds _section_complete, like the quiz).
  3. Choose params — structural only. form_location to tell instances apart; error_type to bucket failures. Never a value the user typed.
  4. Success-gate the conversion — fire _submit inside the backend's success branch, not on click. A click that fails is not a conversion.
  5. Run the DSGVO checklist above, then register the GTM trigger/tag.

Copy site/archetypes/contact-form.html and adapt — it already wires all four events the right way.