Storefront markup
Last updated: September 12, 2026
Simple Split Testing is markup-driven. You add data attributes to elements in your theme; the embed swaps them in or out per visitor. No framework, no build step.
The two required attributes
Every element that takes part in a test needs both:
| Attribute | Value |
|---|---|
data-split-id | The test handle from the admin |
data-split-variant | The variant handle within that test |
Both are visible in the admin on the test detail page, and the app writes them into the snippet it generates for you. Variant handles are usually control, variant-a, variant-b, and so on, and you can rename them. The test’s id and a variant’s id work in place of the handles if you prefer, but handles are what the generated snippet uses.
Element swap
The default behaviour. Each element that carries a data-split-id is kept if the visitor is in the matching variant, and removed from the DOM if not.
<h1 data-split-id="hero-test"
data-split-variant="control">
Welcome to our store
</h1>
<h1 data-split-id="hero-test"
data-split-variant="variant-a">
Big sale on right now
</h1>
A visitor bucketed into control sees the first headline; the second is removed before the page is revealed. A visitor in variant-a sees the opposite.
Author the control first. Whenever the page has markup for a test the visitor has no assignment for, the embed keeps the first element for that data-split-id and removes the rest. That happens more often than you would expect, all of it normal: the test is still a draft, an audience rule excluded this visitor, the test was paused while the markup stayed in the theme, or the storefront could not reach the app. In every one of those cases the visitor gets the default experience, which is the control only, never both headlines at once.
Style swap
Apply variant-specific inline styles to the same element instead of duplicating markup.
<button data-split-id="cta-color"
data-split-variant="green"
data-split-styles="background: #2A8A4F; color: white;">
Buy now
</button>
When the visitor is assigned to green, the embed writes those declarations onto the element. Visitors in other variants are not assigned to this element, so it is removed.
To keep the same element across variants and only change the style, render one element per variant on the page and give each the same content:
<button data-split-id="cta-color"
data-split-variant="green"
data-split-styles="background: #2A8A4F; color: white;">
Buy now
</button>
<button data-split-id="cta-color"
data-split-variant="blue"
data-split-styles="background: #2563EB; color: white;">
Buy now
</button>
Properties you can set
Declarations outside this list are dropped, as is any value containing url(, expression(, javascript: or @import. Use a class swap when you need something not listed here.
background, background-color, color, border, border-color, border-radius, border-width, border-style, opacity, display, visibility, font-size, font-weight, font-family, font-style, line-height, text-align, text-decoration, text-transform, letter-spacing, margin (and each side), padding (and each side), width, height, max-width, max-height, min-width, min-height, flex, flex-direction, justify-content, align-items, gap, box-shadow, transform, transition.
Class swap
Add or remove CSS classes when the visitor is in the assigned variant.
<button data-split-id="cta-color"
data-split-variant="blue"
data-split-add-classes="btn--blue large"
data-split-remove-classes="btn--default">
Buy now
</button>
data-split-add-classes- space-separated class names added to the elementdata-split-remove-classes- space-separated class names removed from the element
Useful when your theme already defines variant styles in CSS and you just want to toggle them.
Attribute swap
Vary any element attribute per variant using data-split-set-attr-<name>="<value>". When the visitor is assigned to that variant, the embed writes <name>="<value>" onto the element. This is how you test CTA destinations, hero images, or aria-label text without duplicating the element.
<a data-split-id="hero-cta"
data-split-variant="treatment"
href="/collections/all"
data-split-set-attr-href="/collections/sale"
data-split-set-attr-aria-label="Shop the sale">
Shop
</a>
When treatment is assigned: href becomes /collections/sale, aria-label becomes Shop the sale. When another variant is assigned, this element is removed, the same as any element swap.
Allowed target attributes
Anything outside this allow-list is dropped silently.
| Category | Attributes |
|---|---|
| Standard | href, src, srcset, sizes, alt, title, value, placeholder, poster, loading, decoding, width, height, target, rel, download, type, media, name |
| ARIA | any aria-* attribute |
| Data | any data-* attribute, except the reserved data-split-* namespace |
Not allowed
These are dropped silently. Use the alternative listed:
| Attribute | Use instead |
|---|---|
style | data-split-styles |
class | data-split-add-classes / data-split-remove-classes |
Event handlers (onclick, any on*) | Read the assignment with window.splt and bind in JS |
srcdoc, formaction, action, http-equiv, is | Out of scope. Rewrite the variant element instead |
URL-bearing attributes (href, src, srcset, poster) additionally reject values containing javascript: or vbscript:.
Template-tag insertion
To avoid first-paint cost for the non-assigned branches, wrap the variant’s content in a <template> tag carrying the data attributes. The embed inserts the template’s contents in its place, only when the visitor is in the matching variant.
<template data-split-id="banner-test"
data-split-variant="promo">
<div class="promo-banner">Free shipping today!</div>
</template>
Browsers do not render <template> contents, so visitors in other variants pay nothing for the markup. Use this for hero swaps, banners, or any sizeable variant-only block.
Combining attributes
You can combine data-split-styles, data-split-add-classes, data-split-remove-classes, and any number of data-split-set-attr-<name> on the same element. All are applied when the variant matches.
<a data-split-id="hero-cta"
data-split-variant="bold"
data-split-add-classes="cta--bold"
data-split-styles="font-weight: 700; text-transform: uppercase;"
data-split-set-attr-href="/collections/sale"
href="/collections/all">
Shop the sale
</a>
Where you can put the markup
Anywhere in your theme:
- Inside sections (
sections/*.liquid) - Inside snippets (
snippets/*.liquid) - Inside theme-app-block schemas
- Directly in
layout/theme.liquid - Inside metafield-rendered content, as long as the data attributes survive your sanitiser
Assignments are applied once the DOM is ready, then re-applied on shopify:section:load and turbo:load so AJAX section updates and Hotwire-based themes pick up variants without a full reload. Markup injected at any other moment is not handled automatically. If you inject your own, dispatch one of those events on document afterwards.
Listening for assignments in your own code
For custom theme JavaScript, analytics tagging, or anything that loads after the embed, use the window.splt global. It exists from the moment the embed starts running, before the assignments resolve.
// Properties
window.splt.assignments // Array<{ splitTestId, splitTestHandle, variant: { id, handle } }>
window.splt.isPreview // true when viewing through a preview link
window.splt.debug // true when ?splt_debug=true is set
// The variant handle this visitor is in for a test, or null.
// Takes the test handle or the test id.
const variant = window.splt.getVariant('hero-cta');
// Subscribe. Fires once when assignments resolve. If they have already
// resolved by the time you subscribe, your callback fires immediately
// with the current payload, so a late-loading script is safe.
window.splt.onAssignments((list) => {
list.forEach(({ splitTestId, variant }) => {
gtag('event', 'experiment_impression', {
experiment_id: splitTestId,
variant_id: variant.handle,
});
});
});
onAssignments always fires, including with an empty list when there is nothing to apply (no active tests, the visitor declined analytics consent, or the request failed), so your callback never hangs waiting for a payload that will not arrive.
The variant payload is { id, handle } only. Names and descriptions stay server-side so storefront visitors cannot read your test copy.
apply_assignments CustomEvent (still supported)
The original event-based API is still wired up. Use it when you need a hook that fires every time assignments are applied, which is the initial load plus each shopify:section:load and turbo:load:
document.addEventListener('apply_assignments', (event) => {
const { assignments } = event.detail;
const heroTest = assignments.find((a) => a.splitTestHandle === 'hero-test');
if (heroTest?.variant?.handle === 'variant-a') {
// run variant-a-specific JS
}
});
This event does not fire when there is nothing to apply. If the assignment request fails, or the shop has no active test running, the embed falls back to control without dispatching it. Use window.splt.onAssignments when you need to run either way.
What the script will not do
- It will not load remote CSS or JS per variant. Stick to inline styles, classes, and DOM you already render.
- It will not run on the checkout. Checkout is governed by Shopify and not by your theme. Test checkout-affecting changes upstream, on the product or cart page.
- It will not bucket a visitor who has declined analytics or sale-of-data consent through your consent banner. Those visitors see the control and are not counted in any test. If they accept later, the page reloads and bucketing resumes.
Fallback when the assignment fetch fails
If the request errors out or the visitor’s connection drops, the embed collapses to control: for each data-split-id, it keeps the first matching element on the page and removes the rest. This is the same rule as a test that is simply not running, which is why the control element goes first in your markup.
Anti-flicker has a matching time limit. The embed hides content while it waits for the assignment, and if the answer has not arrived by then it stops waiting, abandons the request and reveals a clean control page. The limit is set in the app embed’s settings and defaults to 4 seconds. The same settings let you hide the whole page or only the elements taking part in a test, or turn anti-flicker off entirely. A broken embed can never leave the storefront blank.
Debugging
Append ?splt_debug=true to any storefront URL. You get verbose console logging from the embed, a table of the visitor’s assignments once they resolve, and a small panel in the bottom left showing the visitor id with a copy button. Paste that id into the activity log in the admin to see your own visit.
The log line for the swap itself reports how many elements were found on the page, how many were applied, and how many tests were collapsed to control. That last number is what separates “my markup is wrong” from “I never got an assignment for this test”.