← Back to all work
HomeServe webtrends optimize UK home emergency cover provider

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

+5.35% Ding page views 3.27% → 3.44% · 97.3% probability
+10% Progression to Ding 96.9% probability
-2.24% Conversion rate flat, inconclusive: no negative impact

Visibility up, core funnel unharmed. Recommended: implement, then refine (40% of taps were dead-space).

The HomeServe page with and without the global Ding stripe: control, and the variation with a 'Meet Ding' stripe injected above the site header.
Control (left) starts at the site header. The variation (right) injects the Ding stripe above it, site-wide, with the outbound links stamped so the domain hop stays measurable.

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

if we surface a site-wide Ding stripe above the header on qualifying pages then more people discover and reach Ding, without denting the core funnel because a persistent, dismissible prompt makes the repairs option visible wherever the visit starts

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 the bannerClosed logic 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 setInterval every second for the life of the page, with no clearInterval. 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
← Back to all work