1. Copy the files into your theme
| From | To |
|---|---|
theme/blocks/product_configurator.liquid |
blocks/ |
theme/blocks/product_configurator_options.liquid |
blocks/ (optional — split layout) |
theme/sections/product-configurator.liquid |
sections/ (only if blocks are unsupported) |
theme/snippets/product-configurator.liquid |
snippets/ |
theme/snippets/product-configurator-options.liquid |
snippets/ (optional — split layout) |
theme/assets/product-configurator.iife.js |
assets/ |
theme/assets/product-configurator-cart.js |
assets/ |
theme/snippets/product-configurator-cart-preview.liquid |
snippets/ (optional — cart page previews) |
Never hand-edit product-configurator.iife.js. It is built from
packages/visualizer and refreshed with npm run sync:theme.
Optional — load the bundle from our CDN instead
Copying product-configurator.iife.js into assets/ is the default and needs no
third party at runtime. If you would rather not re-copy it on every upgrade, or
you run several stores off one version, point at the published bundle instead:
<script src="https://cdn.simple-configurator.garusin.com/v/0.1.1/product-configurator.iife.js"
type="module" defer></script>
<script src="{{ 'product-configurator-cart.js' | asset_url }}" defer></script>
- The version in the URL is pinned on purpose. There is no
latestand no floating major version, so your product page cannot change behaviour without you editing this line. Upgrading is a deliberate edit. - That URL is immutable. A given version's bytes never change; a fix ships as a new version.
integrity=is worth adding. Each release prints its SRI hash — paste it withcrossorigin="anonymous"and the browser refuses the file if it ever differs.- The cart script still comes from your assets. It is small, and it is the piece most likely to need a theme-specific tweak.
If your theme does not support theme blocks
Copy theme/sections/product-configurator.liquid into sections/ and add it
from the theme editor as a section instead of a block.
The trade-off is placement, and it is the reason the block is preferred wherever it works: a block sits inside the theme's product section, so it can go in the media column or beside Add to cart. A section is a sibling of that section, so it can only stack above or below it — which also means the split layout in step 5 is not available. The section renders the preview and controls together.
Failing that, render the snippet directly from your product template. The snippets emit markup only — the block and the section are what load the assets — so load them yourself, once per page:
<script src="{{ 'product-configurator.iife.js' | asset_url }}" type="module" defer></script>
<script src="{{ 'product-configurator-cart.js' | asset_url }}" defer></script>
{% render 'product-configurator', product: product, uid: section.id %}
2. Create the metafield definition
Shopify admin → Settings → Custom data → Products → Add definition
| Name | Product configurator config |
| Namespace and key | configurator.config |
| Type | JSON |
| Pin | Yes, so it shows on the product edit page |
Liquid can read product metafields without the Storefront API access toggle, so you do not need to enable it. Turn it on anyway if you expect a headless surface to read the same config later.
The admin validates JSON syntax only, not our schema. A syntactically valid but semantically wrong config renders nothing and logs to the console. Always paste from the editor, which validates against the real schema.
3. Add the config
Build it in the editor (npm run dev:editor), hit Copy for metafield, and
paste into the product's configurator.config field.
To try it before creating the metafield, paste the JSON into the block's Config JSON override setting instead.
4. Add the block
Theme editor → product template → Add block → Product configurator.
5. Optional — split the preview from the controls
By default one block renders the canvas and the option controls together. On most themes that is not where they belong: the preview wants the media column and the swatches want to sit beside Add to cart, and those are different DOM subtrees — one element cannot span both.
Two blocks, no code:
- Set the Product configurator block's What this block renders to Preview only, and place it in the media column.
- Add a Configurator options block beside Add to cart.
That is the whole setup. The options block has no config setting and no target setting to fill in: with its Configurator selector left blank it binds to the first configurator on the page, which is what a product page has. A block cannot know the id of a block rendered elsewhere in the section, so blank is the default rather than something you are expected to look up.
Two things worth knowing:
- Set the preview block to "Preview only". Leave it on Preview and options and the controls appear twice. The component warns in the console rather than failing, since duplicated controls still submit correctly.
- The hidden cart inputs stay with the preview block, not the options block. Where the controls sit has no effect on what reaches the order — the controls hold no state, they read from and write to the canvas element.
If your theme does not support theme blocks, render the snippets instead. Pass
the same uid to both, since neither can discover the other:
{%- comment -%} media column {%- endcomment -%}
{% render 'product-configurator', product: product, uid: section.id, ui: 'canvas' %}
{%- comment -%} beside Add to cart {%- endcomment -%}
{% render 'product-configurator-options', product: product, uid: section.id %}
Only the block and the section emit the <script> tags. Rendering the snippets
directly means loading the two assets yourself, once per page:
<script src="{{ 'product-configurator.iife.js' | asset_url }}" type="module" defer></script>
<script src="{{ 'product-configurator-cart.js' | asset_url }}" defer></script>
Driving it from controls the theme already has
If you would rather use your own swatch markup, dispatch configurator:set:
<button data-thread="navy">Navy</button>
<script>
document.addEventListener('click', (e) => {
const t = e.target.closest('[data-thread]');
if (!t) return;
document.dispatchEvent(
new CustomEvent('configurator:set', {
detail: { for: '#pc-{{ section.id }}', thread: t.dataset.thread },
}),
);
});
</script>
No script import and no element reference — an event fired before the component
hydrates is replayed once it does. for is a CSS selector; omit it only if the
page has exactly one configurator. { replace: true } resets to defaults, and
values that do not match an option id are ignored.
The element emits configurator:change and listens for
configurator:set — do not listen for the one you dispatch.
6. Optional — the split layout as a template
You do not need a template file for this. Do it in the theme editor — it is the normal workflow, it takes about the same effort, and unlike a JSON template it cannot fail on a name your theme happens not to use:
- Product template → Add block → Product configurator, set What this block renders to Preview only, drag it into the media column.
- Add block → Configurator options, drag it beside Add to cart.
- On the theme's buy-buttons block, turn dynamic checkout buttons off.
Assign the result per product under Product → Theme template if you want it to apply only to configurable products.
Or use the ready-made template
theme/templates/product.configurator.json now works on any Online Store
2.0 theme, because it names nothing your theme owns. Copy two files:
| From | To |
|---|---|
theme/sections/product-configurator-split.liquid |
sections/ |
theme/templates/product.configurator.json |
templates/ |
Then assign it per product under Product → Theme template → configurator.
You get a two-column product page — preview on the left, options and Add to cart on the right — with a Preview side setting to swap them, and settings for width, gap, padding, swatch size and the button label.
The trade-off is real. This section renders its own title, price and product form rather than the theme's, so the page inherits none of the theme's product-page styling or features — no variant picker, no description, no reviews. That is the price of not naming anything theme-specific. For a page that looks like the rest of the store, use the two blocks in the theme editor above and leave this template alone.
The earlier version of this file nested our blocks inside the theme's own
product section, which meant naming main-product, title, buy_buttons and
the rest — Dawn's names. It failed to import on anything else, which is exactly
what a template naming another author's sections will always do.
What reaches the order
Selections are submitted as line item properties:
Colour: Clay
Fabric: Combed cotton
Pocket: With pocket
_config: {"v":1,"c":"classic-tee","s":{"colour":"clay",...}}
The leading underscore on _config hides it from the shopper in the cart and at
checkout, while keeping it on the order and visible in the admin and the API.
Two Shopify behaviours the schema handles for you, both of which bite if you hand-roll this:
- Empty values are dropped silently.
properties[Pocket] = ""produces no property at all, not an empty one. That is why a "Without" choice is declared incart.properties[].omitWhenrather than emitting an empty string. - Duplicate property names last-win, silently. Two groups mapped to the same
name lose one on the order with no error anywhere.
parseConfigrejects that at author time.
Pricing — read this before promising anything
Line item properties cannot change the price Shopify charges. There is no
property, no attribute and no theme-side trick that changes it. A configurator
that displays "+$35" and charges the base price is a chargeback and a Shopify
TOS problem, so with pricing.mode: "none" the component renders no price UI
at all. priceDelta is carried in the payload for reporting only.
The three ways to actually charge for an option:
| Approach | Cost |
|---|---|
Real variants — map priced groups to product options (pricing.mode: "variant") |
Variants only on the priced dimensions, not the combinatorial ones |
Cart Transform Function (pricing.mode: "function") |
Needs a Shopify app and a deployed Function |
| Hidden add-on product as a second line | Shopper can delete it; breaks per-line reporting and discounts |
Things that break, and what to do
Dynamic checkout buttons. {{ form | payment_button }} — Shop Pay, PayPal,
Google Pay — bypasses the cart and is unreliable for line item properties. On a
configurable product, hide them. Silently losing the configuration between the
product page and the order is the worst failure this system has.
The theme re-renders the product form. Many themes re-render the form via the
Section Rendering API on variant change, which wipes injected inputs.
product-configurator-cart.js re-syncs on the common variant-change events and
keeps a MutationObserver as a backstop. If your theme uses a different event,
add it to the list in that file.
A theme takes over the Add to cart button. Themes claim
button[name="add"] as their own add-to-cart control and manage its state. On a
sold-out product, sections/product-configurator-split.liquid served its button
with disabled and the theme's script removed it, replacing it with
aria-disabled="false" — a clickable "Sold out" button that would post to
/cart/add. That section's button is therefore unnamed. If you write your own
form, either leave the button unnamed or expect the theme to manage it. Nothing
needs the name: the form posts from its action, and
product-configurator-cart.js finds the form by form[action*="/cart/add"],
never by the button.
The wrong form gets picked. A page with quick-add tiles has several
form[action*="/cart/add"]. Set the block's Product form selector setting.
Cart lines not merging. Shopify merges lines with an identical variant and identical properties, so two of the same configuration become quantity 2. That is deliberate — the payload contains no timestamp or nonce. Don't add one.
Personalised lines are the exception and should not merge: two different engravings are genuinely different products, and they differ in the payload, so this falls out correctly with no special handling.
Engraving text arrives as a normal property. A text group's value is the
shopper's own words, so Engraving: Jane Doe lands on the order alongside the
option properties. What the shopper typed is cleaned up before it gets there —
control characters stripped, whitespace collapsed, the config's allowed-character
list enforced, capped at the configured length. Set that list to what your
engraver can actually cut, so a shopper is refused as they type rather than at
fulfilment.
Fonts have to load, from wherever you point them. Either upload the file
(.woff2) to Shopify Files, or paste a Google Fonts stylesheet link — the
https://fonts.googleapis.com/css2?family=… URL, not a link to a .ttf. Both
are handled; a stylesheet gets a <link> in the document head rather than being
loaded as a font binary.
If a font fails to load the console says so explicitly and warns that the export will not match the preview — treat that as blocking. A missing font does not take the page down; text renders in a fallback face with different metrics, which is exactly the silent mismatch you do not want reaching production.
Hotlinking Google Fonts is a GDPR problem for EU stores. It sends every
shopper's IP address to Google on every product page, which German courts have
ruled unlawful without consent. Fine for prototyping and for stores outside the
EU; for anything else download the .woff2 and upload it to Shopify Files. The
parser and the editor both warn. Fonts do not taint the canvas, so this is a
privacy and reliability call, not an export one.
Assets must live in Shopify Files. Layer art served from anywhere without
CORS headers taints the canvas, and toBlob() then throws SecurityError at
add-to-cart time on a live store. The editor warns about non-Shopify hosts while
you are authoring, and the component probes each origin at load time so the
failure is loud in development rather than silent in production.
Config too big. A metafield value caps at 65,535 bytes. The editor shows a
byte meter. If you are close, set assets.baseUrl — Shopify Files URLs run ~120
characters each, so 30 layers of absolute URLs is ~4KB of pure prefix.
Preview images
toBlob() produces a PNG of the current configuration, but it cannot go in a
line item property — property values can't hold a few hundred KB, and the value
is truncated or dropped without an error.
The shipped approach re-renders instead: product-configurator-cart-preview.liquid
draws the line from its _config payload plus the product's metafield. No
infrastructure, and it can never drift from the product page.
That needs JavaScript, so it does not appear in the confirmation email or the
admin order view. Those need a genuinely uploaded image: toBlob() → POST to an
app proxy or a presigned URL → put the returned URL in properties[_preview].
That is the point at which this needs a backend, which is why it isn't here.
For fulfilment the properties themselves (Colour: Clay, Fabric: Combed cotton)
are usually sufficient; the image is a nicety.
Upgrading the bundle
npm run build -w packages/visualizer
npm run sync:theme
Then copy theme/assets/product-configurator.iife.js into the theme again. The
file carries a version banner so a support conversation can start with "what
version are you on".