Tiny, copy-paste JavaScript helpers for the problems that actually break client side A/B tests.
No build step. No install. No dependencies. Each helper is one standalone file you paste straight into your variation code. They are vendor-neutral, so they work the same on Optimizely, VWO, Convert, Adobe Target, or your own rig.
Every helper is the cleaned-up version of a technique used on real client builds. The full write-up behind most of them lives at π arafatcro.dev/guides.
Client side tests live on top of pages you do not control. Elements render late, frameworks reclaim the DOM, SPAs never reload, and one thrown error can take down the host page. These helpers handle those sharp edges so your variation behaves, and so a mistake in your code can never break the client's site.
The set splits into three jobs: apply the variation reliably, trigger it at the right moment, and persist so you do not nag the same visitor.
| Helper | Job | What it does |
|---|---|---|
β³ waitFor |
Apply | wait for an element or a condition |
π waitForAll |
Apply | wait until several targets are all ready |
π waitForAny |
Apply | fire on the first of several targets |
π§ onRouteChange |
Apply | re-run on SPA navigation |
π hideUntilApplied |
Apply | element-scoped anti-flicker |
βοΈ setReactValue |
Apply | set a React controlled input |
βΏ trapFocus |
Apply | accessible variant focus trap |
π exitIntent |
Trigger | fire when the visitor goes to leave |
π onElementVisible |
Trigger | fire when an element scrolls into view |
πͺ cookies |
Persist | get / set / remove cookies |
π frequencyCap |
Persist | show once per session or per N days |
Get the variation onto the page reliably, without breaking the host site.
Wait for a DOM element or an arbitrary condition, then fire callback once.
- π Selector (string): polls
document.querySelectorand firescallback(element)on the first match. - π§ Predicate (function): polls the function and fires
callback(null)when it returns truthy. Use this for non-DOM waits, like() => typeof window.tealiumDataLayer === 'object'.
Built for variant code: it polls every 50ms, gives up silently after timeoutMs (default 5000), and wraps the lookup, the predicate, AND the callback in try/catch, so nothing here can break the host page. Fires at most once. Returns a cancel(). Exported as waitForElement too.
waitFor('[data-qa="add-to-basket"]', (el) => { /* apply the variation */ });
waitFor(() => window.dataLayer?.length > 0, () => track('ready'));
const cancel = waitFor('.late', apply); // call cancel() to stop earlyπ Full write-up: How to wait for an element in an A/B test
Same poll loop, for a combination of things. targets is an array of selectors and/or predicates, mixed freely.
- β
waitForAllfires once every target is satisfied, and hands you the results in order (callback(results)), so you never re-query.results[i]is the matched element for a selector, ortruefor a predicate. - π
waitForAnyfires as soon as the first target is satisfied, withcallback(result, index), so you know which one won. Handy when a layout can render one of several ways.
// wait for all three, then use the elements directly
waitForAll(['.price', '.add-to-cart', () => window.app?.ready], ([price, cta]) => {
// price and cta are the matched elements; app is ready
});
// whichever modal renders first
waitForAny(['.modal-v1', '.modal-v2'], (el, i) => trapFocus(el));π‘ You can always do a combination with a single
waitForpredicate too (() => a && b).waitForAlljust saves you the re-query by handing the elements back.
Re-fire your test logic when a single-page app navigates. onRouteChange patches History once (guarded so experiments cannot stack patches) and listens for popstate. onUrlChange is the fallback when you cannot touch History, inferring navigation from DOM changes.
const run = () => { teardown(); /* re-apply the variation for this view */ };
run(); // first load
onRouteChange(run); // every navigation afterπ Full write-up: Optimizely experiment not firing on SPA route changes
Element-scoped anti-flicker. Hide only the elements your variation changes, then reveal on apply with a failsafe timeout. Uses visibility:hidden so the element keeps its space and revealing it does not trigger a layout shift.
const reveal = hideUntilApplied('[data-qa="hero-cta"]');
waitFor('[data-qa="hero-cta"]', (el) => {
el.textContent = 'Start free trial';
reveal();
});π Full write-up: How to stop A/B test flicker without killing LCP
Set a React controlled input so React's own value tracker updates and onChange fires. A plain input.value = x is silently ignored by React. Goes through the native setter and fires both input and change.
const qty = document.querySelector('input[name="quantity"]');
setReactValue(qty, '3'); // basket total and stock check now recalculateπ Full write-up: Changing a React controlled input in an A/B test
Trap keyboard focus inside one modal, drawer, or variant your variation opens, then restore focus when it closes. Inerts the sibling content, loops Tab, closes on Escape. Returns a close function.
const close = trapFocus(document.querySelector('.my-variant-modal'));
closeButton.addEventListener('click', close);π Full write-up: Building accessible A/B test variants
Fire the variation at the right moment.
Fire when the visitor shows intent to leave. On desktop that is the cursor leaving through the top of the viewport. On touch, where the pointer never leaves, it falls back to a fast upward scroll and an optional inactivity timeout. Fires once by default and returns a teardown you can call to cancel early.
const cancel = exitIntent(() => showOffer(), { idle: 15000 });
// cancel() if the visitor converts before the offer is relevantβοΈ Options: sensitivity (px from the top edge), mobileScrollDelta (px of upward flick), idle (ms of inactivity, 0 = off), once.
πΊοΈ Guide coming: exit-intent A/B tests.
Fire the first time an element scrolls into view, via IntersectionObserver. Good for impression tracking, lazy-applied variations, and scroll-triggered changes. Fires once per element by default; returns a teardown.
onElementVisible('.pricing-table', () => track('pricing_seen'));βοΈ Options: threshold, once, root, rootMargin.
Remember what a visitor has already seen, so you do not nag them.
Minimal cookie read/write. Persist a variation assignment, remember that a visitor saw something, or read an existing cookie for targeting. setCookie defaults to 30 days, root path, SameSite=Lax, and Secure on https.
setCookie('exp_hero', 'variant_b', { days: 14 });
if (getCookie('exp_hero') === 'variant_b') applyVariant();Show a popup, banner, or one-time variation at most once per session or once per N days. Each returns true the first time and false after, so you gate inline. Uses Web Storage, so it is self-contained, and fails open if storage is blocked.
exitIntent(() => {
if (allowOncePerDays('exit_offer', 14)) showOffer();
});πΊοΈ Guide coming: frequency capping for popups and banners.
Each file in src exports its function as an ES module, so you can import it:
import { waitFor, waitForAll } from './src/waitFor.js';
import { exitIntent } from './src/exitIntent.js';Or, for a variation editor that takes a plain script, copy the function body straight out of the file and drop the export line. There are no other dependencies, and the helpers do not import each other, so every function stands alone. βοΈ
The repo runs ahead of the writing. These helpers are the next write-ups planned for arafatcro.dev/guides:
- π
exit-intent-ab-testβ getting exit intent right on desktop and mobile - π
frequency-capping-popupsβ show it once, not on every page
Maintained by Arafat, a freelance CRO developer. I build and ship client side experiments and write up the hard parts at arafatcro.dev. If a helper here saved you time, the guides go deeper on the why.