A site-wide stripe that keeps attribution across a domain hop
The most involved build of the set. A Ding stripe above the header on qualifying pages, that remembers when a user dismisses it, and, when that user reaches the repairs page, rewrites the outbound links to the Ding domain so the cross-domain journey stays measurable. Here is how it holds together, and the honest account of what shipped but does nothing.
I build and QA tests like this for brands and CRO agencies. Start a test brief →
Result
Visibility up, core funnel unharmed. Recommended: implement, then refine (40% of taps were dead-space).
The challenge
This one does more than inject a banner, it has three jobs. It runs site-wide, so the code has to behave on any page. It has to respect a user who closes the banner and keep it closed as they move around. And because Ding, the repairs product, lives on a separate domain, the experiment has to hand its attribution across that boundary so the cross-domain visit can still be tied back to the test.
The cross-domain handoff is the interesting half, and the reason this is the most involved build of the three.
The hypothesis
The trigger: one file, two jobs, decided by URL
The trigger checks the path. On the repairs page it injects nothing; it waits for the Optimize data and then decorates the outbound Ding links. Everywhere else it does the ordinary thing and calls activate() once the header exists. The split lives in the very first check.
if (window.location.href.includes('/repairs/')) {
// Repairs page: stitch experiment attribution onto outbound ding.co.uk links.
pollerLite(
[
() => typeof window.dataLayer !== 'undefined',
() => typeof window.dataLayer?.find((i) => i.event === 'optimize_view') === 'object',
],
() => {
setInterval(() => {
const item = window.dataLayer?.find((i) => i.event === 'optimize_view');
if (!item) return;
const { wto_uid, test_id, experiment_id } = item;
document.querySelectorAll('a[href*="ding.co.uk"]').forEach((link) => {
const url = new URL(link.href);
if (!url.searchParams.has('wto_uid')) {
url.searchParams.set('wto_uid', wto_uid);
url.searchParams.set('wto_test_id', test_id);
url.searchParams.set('wto_experiment_id', experiment_id);
link.href = url.toString();
}
});
}, 1000);
}
);
} else {
// Everywhere else: inject the global banner once the header exists.
pollerLite(['body', '#header-block'], activate);
}
Carrying the experiment across the boundary
Ding sits on its own domain, so a normal analytics session would break the moment a user clicks through. The fix is a handoff. Banner pages record the experiment in sessionStorage; the repairs page reads that, plus the Optimize ids from the dataLayer, and stamps them onto every ding.co.uk link before the user leaves. The visit lands on the other domain still carrying its attribution.
export const storeItemInSession = (newItem) => {
const key = 'bl-experiments';
let arr = [];
try {
arr = Array.isArray(JSON.parse(sessionStorage.getItem(key))) ? JSON.parse(sessionStorage.getItem(key)) : [];
} catch { arr = []; }
// Dedupe, tolerate malformed JSON, append the experiment id the repairs page later reads.
if (!arr.includes(newItem)) {
sessionStorage.setItem(key, JSON.stringify([...arr, newItem]));
}
};
The stripe, injected once
init() checks for a prior dismissal, inserts the stripe before #header-block so it sits at the very top of the page, and fires a one-time seen event. The banner itself is a static component using a hosted Ding image.
const init = () => {
// Respect a session-level dismissal: closed once, stays closed.
if (sessionStorage.getItem(`${ID}__bannerClosed`) === 'true') return;
// Inject the stripe above the site header, once.
if (!document.querySelector(`.${ID}__bannerWrapper`)) {
document.querySelector('#header-block').insertAdjacentHTML('beforebegin', banner(ID));
}
// One-time seen event (the banner is at the top, so it is seen on load).
if (!document.body.classList.contains(`${ID}__seenBanner`)) {
fireEvent('Users sees banner');
document.body.classList.add(`${ID}__seenBanner`);
}
};
Instrumentation, including the clicks that miss
Tracking uses the shared GA4 framework. What stands out is that the click delegation counts the misses: taps on the banner background that hit neither the CTA nor a close control. That dead-space event is exactly what surfaced the finding that 40% of banner interactions were wasted, which drove the recommendation to refine the design rather than ship it unchanged. Instrumenting the failure modes, not just the wins, is what made that visible.
document.body.addEventListener('click', (e) => {
const { target } = e;
if (target.closest(`.${ID}__text a`)) {
fireEvent('User interacts with banner cta');
} else if (target.closest(`.${ID}__closeWrapper`)) {
fireEvent('User closes banner');
target.closest(`.${ID}__bannerWrapper`)?.remove();
sessionStorage.setItem(`${ID}__bannerClosed`, 'true');
} else if (target.closest(`.${ID}__bannerWrapper`) && !target.closest(`.${ID}__learn-more`)) {
fireEvent('User clicks dead space, not the CTA or close'); // the branch that paid off
} else if (target.closest(`.${ID}__learn-more`)) {
fireEvent('User clicks find out more');
}
});
What I would harden next
The cross-domain handoff is the strong part of this build. The banner side has three things that were shipped but do nothing, and I would rather name them than let them read as working.
- The sticky class has no CSS. A scroll listener toggles
mobile-sticky, but the matching rule in the stylesheet is commented out, so the sticky behaviour never happens on screen. On a test where nearly two thirds of interaction was mobile, that is the first thing to finish or remove. - The close control is coded but not rendered. The click handler and the stylesheet both cover a
closeWrapper, yet the banner markup never renders one. As written there is no way to dismiss the stripe, so the close event and thebannerClosedlogic can never fire. - A tracked CTA that cannot fire. The first click branch matches
.__text a, but there is no anchor inside.__text. The real CTA is.__learn-more, which has its own branch. That first event is dead, remove it. - The link decorator never stops. On the repairs page the decoration runs in a
setIntervalevery second for the life of the page, with noclearInterval. A MutationObserver, or clearing after the first pass, would be lighter. (The trailing-space id bug in the dataLayer fallback is the same one carried by the earlier two tests, worth fixing once in the shared helper.)
The result: visibility up, sales unharmed
The banner did its job. More people reached Ding, without denting the core funnel: Ding page views rose 5.35% and progression to Ding 10%, while the conversion movement was a flat, inconclusive dip read as no negative impact. The team recommended implementing, then refining the design, a call driven partly by the 40% dead-space clicks the code had measured. A win on the target metric should still be honest about the design work it revealed.
- Placement: stripe above the site header, site-wide (homepage and comparison pages)
- Devices: all devices (64.5% of taps were mobile)
- Live: 13 May to 30 June 2025 (48 days)
- Sessions: 77,024 control, 80,755 variant