THE CTA EXPERIMENT
Let the response
settle the wording.
Test calls to action consistently across the website. Connect the wording people see to the action they take, so the next change starts with evidence.
- Discipline
- Conversion experimentation
- Context
- US executive search firm
One visitor. One consistent variant. Measurable actions.
Wording decided by whoever felt strongest.
Every service page and the header carried a call-to-action, and the wording was decided the way wording usually is: whoever felt strongest. The site runs on WordPress and Elementor, where the standard A/B tools either flash the original text before swapping it, fight the theme, or bring a plugin and a tracking layer of their own.
The task: run controlled wording variants on every CTA on the site, site-wide, per session, with no flash and no plugin, and send the events through the GTM and GA4 setup that already existed.
One script. It does four things and nothing else.
Installed once through Elementor's custom code at the head of every page. Each stage shows the effect on the visitor or your team. Expand the implementation when you want to inspect the technical detail.
Picks a variant
Three wordings, each with a long form for body buttons and a short form for the header. A visitor is assigned one at random and keeps it for thirty minutes, stored locally, so a session sees one wording everywhere. Assignment is per session on purpose: it lines up with how GA4 counts a session, so the test is analysed on the unit it was assigned on.
View the implementation15 lines +
From the script, with my comments for you.
function pickVariant() {
// One wording per session, held for thirty minutes in localStorage. That lines
// up with how GA4 counts a session, so the unit the test is assigned on is the
// unit it gets analysed on. Change SESSION_MS if you want it per user instead.
var now = Date.now(), stored = null;
try { stored = JSON.parse(localStorage.getItem(STORAGE_KEY)); } catch (e) {}
if (stored && stored.id && (now - stored.t) < SESSION_MS) {
stored.t = now;
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(stored)); } catch (e) {}
for (var i = 0; i < VARIANTS.length; i++) if (VARIANTS[i].id === stored.id) return VARIANTS[i];
}
var v = VARIANTS[Math.floor(Math.random() * VARIANTS.length)];
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: v.id, t: now })); } catch (e) {}
return v;
}Finds the buttons
A button is any link pointing at one of the conversion destinations. Navigation links, menu items, and bare menu words like "Contact" are excluded, so a nav link never gets rewritten. Inside the button, the script walks the text nodes and picks the label, leaving arrows and icons alone.
View the implementation20 lines +
The label walker. Arrows and icons stay where they are.
function findLabel(btn) {
// Elementor buttons carry arrows and icons inside them. Walk the text nodes,
// take the longest one as the label, and wrap only that, so the arrow stays.
var existing = btn.querySelector('.mi-cta-label');
if (existing) return existing;
var walker = document.createTreeWalker(btn, NodeFilter.SHOW_TEXT, null, false);
var best = null, bestLen = 0, n;
while ((n = walker.nextNode())) {
var t = n.textContent;
if (!t || DECOR.test(t)) continue;
var len = t.trim().length;
if (len > bestLen) { bestLen = len; best = n; }
}
if (!best || bestLen < 2) return null;
var span = document.createElement('span');
span.className = 'mi-cta-label';
best.parentNode.insertBefore(span, best);
span.appendChild(best);
return span;
}Hides the swap
A style rule makes the original text transparent until the new text is in place, then lifts. If anything fails, a failsafe lifts it after two and a half seconds so a visitor never sees an empty button.
View the implementation12 lines +
The cloak.
// The reason people say this can't be done on Elementor without a plugin is the
// flash: the original wording shows for a frame before the swap. So the original
// text is made transparent until the new text is in place, and a failsafe lifts
// the cloak after 2.5 seconds no matter what, so nobody ever sees an empty button.
var cloak = document.createElement('style');
cloak.id = 'mi-cta-cloak';
cloak.textContent = INCLUDE.split(',').map(function (s) {
return s.trim() + ':not([data-cta-rendered])';
}).join(',') + '{color:transparent !important;}';
(document.head || document.documentElement).appendChild(cloak);
function uncloak() { if (cloak.parentNode) cloak.parentNode.removeChild(cloak); }
setTimeout(uncloak, CLOAK_FAILSAFE);Stamps the contract
Four attributes on every button it touches: the text actually shown, a stable id, whether it's header or body, and the variant. That's the whole interface with analytics. GTM reads the attributes and sends view and click events. The script never talks to GA4, so the tracking can change without the script changing. Because Elementor renders late and re-renders on interaction, the script watches the page for the whole session and re-applies to anything new.
View the implementation26 lines +
The swap and the four attributes, then the observer.
function applyTo(btn) {
if (btn.__miDone) return;
btn.__miDone = true;
var short = isShort(btn);
var area = short ? 'header' : 'body';
var text = short ? variant.short : variant.long;
var label = findLabel(btn);
if (label) label.textContent = text;
// These four attributes are the whole contract with analytics. Tag Manager
// reads them and sends cta_view and cta_click. This script never talks to
// GA4 itself, so the tracking can change without the script changing.
if (!btn.getAttribute('data-cta-id')) {
btn.setAttribute('data-cta-id', location.pathname + '#' + area + '-' + (++autoN));
}
btn.setAttribute('data-cta-area', area);
btn.setAttribute('data-cta-variant', variant.id);
btn.setAttribute('data-cta-rendered', text); // set last: this is what lifts the cloak
}
// Elementor renders late and re-renders on interaction, so watch the page for
// its whole life and re-apply to anything new.
new MutationObserver(function () {
if (pending) return;
pending = true;
(window.requestAnimationFrame || setTimeout)(function () { pending = false; sweep(); }, 16);
}).observe(document.documentElement, { childList: true, subtree: true });Sends the events
Tag Manager reads the four attributes off the element that was seen or clicked and sends two events to GA4, cta_view and cta_click, each carrying the variant, the area, the id and the wording shown. GA4 registers those as custom dimensions once, and from then on the read is an exploration: clicks over views, per variant, per area. Nothing about the test lives in the script and nothing about the script lives in the tag.
View the implementation20 lines +
The tracking, end to end. Everything the site sends about the test, and where it goes.
# What leaves the page, and where it goes. Nothing here is in the rotator script. Google Tag Manager Variable DOM element cta_variant reads data-cta-variant from the clicked or seen element Variable DOM element cta_area reads data-cta-area Variable DOM element cta_id reads data-cta-id Variable DOM element cta_text reads data-cta-rendered, the wording actually shown Trigger Element visibility any [data-cta-rendered], once per element, 50% in view -> cta_view Trigger Click, all elements matches a[data-cta-rendered] -> cta_click Tag GA4 event cta_view with cta_variant, cta_area, cta_id, cta_text Tag GA4 event cta_click with the same four GA4 Custom dimensions cta_variant, cta_area, cta_id, cta_text registered once, event-scoped The read an exploration: sessions and cta_click by cta_variant, filtered by cta_area The number clicks over views, per variant, per area. The winner is the one that clears it. Why the attributes and not the script The script never calls GA4. If the events change, GTM changes. The script does not. If the wordings change, VARIANTS changes. GTM does not.
The wording, measured.
Nothing goes in this section until it's pulled from GA4: weeks run, sessions per variant rounded, click rate per variant, the winner and the lift, and whether it cleared significance.
GA4 has the answer. Nobody has to argue.
Variant, area and id on every view and click, so "which wording" is a report rather than a meeting. Stack: JavaScript, Elementor custom code, Google Tag Manager, GA4.
Any site where the wording gets argued about can run this, and the first read comes in six weeks. What the wordings are is the part that has to be yours.
What’s getting in the waySpecific examples from your public website.
What it means for the buyerWhy each issue is worth your attention.
What I would fix firstA clear order of priorities for you or your team.
Free. No account access. No call required.