The bundle registers two elements.
<product-configurator>renders the product;<product-configurator-options for="#id">renders the controls and can live anywhere on the page. See the component split below.
The single source of truth exchanged between the editor (produces it) and the
visualizer (consumes it). Mirrored from packages/schema/src/types.ts — when
those types change, this document changes.
Validation and normalization live in packages/schema/src/parse.ts. Hand-rolled,
zero dependencies: this module is bundled into the storefront widget where Zod
alone would be ~13KB gzip, half the entire budget. What is actually needed here
is normalization — defaulting, baseUrl resolution, paint ordering,
cross-reference checking — which a validator gives you none of.
parseConfig(raw): Config // throws ConfigError — the visualizer's entry point
validateConfig(raw): { config, errors, warnings } // never throws — the editor's
resolve(config, selection): Resolved // settles rules to a fixpoint
renderPlan(config, resolved): DrawOp[] // pure; testable without a canvas
toCartSummary(config, resolved): CartSummary // what the theme puts on the line
Example
{
"schemaVersion": 1, // REQUIRED. Bumped only on a breaking change.
"id": "classic-tee", // REQUIRED. Travels on the order for traceability.
"title": "Classic Tee",
"assets": {
"baseUrl": "https://cdn.shopify.com/s/files/1/0812/3345/files",
"previewWidth": 1400, // on-screen raster cap; export uses full size
},
"stage": { "width": 1200, "height": 1400, "background": "#f6f6f7" },
// ARRAY ORDER IS PAINT ORDER — index 0 draws first (bottom). There is no `z`.
"layers": [
{
"id": "body",
"label": "Shirt body",
"src": "shirt-mask.png", // relative → resolved against assets.baseUrl
"role": "mask",
"tint": { "color": "#ece8e1", "mode": "flat", "strength": 1 },
},
{
"id": "body-shade",
"src": "shade-cotton.png",
"role": "shade",
"opacity": 0.9,
"clipTo": "body",
},
{
"id": "body-light",
"src": "light.png",
"role": "light",
"opacity": 0.55,
"clipTo": "body",
},
{ "id": "logo", "src": "logo-star.png", "role": "art", "visible": false },
],
"groups": [
{
"id": "colour",
"label": "Colour", // shopper-facing
"ui": "swatch", // swatch | select | thumbs | toggle
"required": true,
"default": "bone", // empty → the first option
"showIf": [], // ALL clauses must pass; empty = always shown
"options": [
{
"id": "bone",
"label": "Bone", // shopper-facing, AND the value on the order
"swatch": "#ece8e1",
"priceDelta": 0, // metadata only — see Pricing
"showIf": [],
"effects": [{ "type": "tint", "layer": "body", "color": "#ece8e1" }],
},
],
},
{
"id": "logo",
"label": "Chest print",
"ui": "select",
"required": false,
"showIf": [{ "group": "fabric", "in": ["cotton"] }],
"options": [
/* … */
],
},
],
"pricing": { "mode": "none", "currency": "", "variantBinding": [] },
"cart": {
"payloadKey": "_config", // leading _ hides it from the shopper
"properties": [
{ "group": "colour", "as": "Colour", "omitWhen": [] },
{ "group": "logo", "as": "Print", "omitWhen": ["none"] },
],
},
"ui": { "layout": "side", "showReset": true },
}
The two elements
<product-configurator> |
Renders the product. With ui="full" (the default) it also renders the controls inline — the drop-in case. With ui="canvas" it renders only the canvas. |
<product-configurator-options for="#id"> |
Renders only the controls, driving the configurator it points at. |
<!-- media column -->
<product-configurator id="cap" config="#cfg" ui="canvas"></product-configurator>
<!-- info column, beside Add to cart -->
<product-configurator-options for="#cap"></product-configurator-options>
They exist as a pair because of how product pages are actually built: the preview belongs in the media column and the swatches beside Add to cart, and on every Shopify theme those are different DOM subtrees — not siblings you can wrap in one element.
The options element holds no state. It reads the configurator's settled
selection and calls back into it, so there is one source of truth however many
are on the page — and driving the canvas directly updates every bound options
element. Both render from one shared module, so ::part() names and keyboard
behaviour are identical whichever you style.
Set ui="canvas" on the configurator when pairing them, or the controls appear
twice. The options element warns in the console if you forget.
It also tolerates being rendered before its target: it watches for the
configurator to appear, and waits for customElements.whenDefined if the script
order puts it first. A selector that never matches warns after a few seconds
rather than failing silently.
register(prefix) renames both tags together, so the pair can never drift
onto different generations of the element.
Driving it from your own controls
The element emits configurator:change and listens for configurator:set —
a deliberately different name, because listening for the event you also emit is
an infinite loop waiting to happen.
// From anywhere on the page, with no reference to the element.
document.dispatchEvent(
new CustomEvent('configurator:set', {
detail: { for: '#cap', thread: 'navy' },
}),
);
// Or straight at it, if you already have one.
cap.dispatchEvent(new CustomEvent('configurator:set', { detail: { thread: 'navy' } }));
in detail |
|
|---|---|
| any string key | part of the selection — the flat form |
selection |
the explicit form; wins over flat keys if both are given |
for |
CSS selector naming which configurator(s) to drive. Omit it and it applies to all, which warns if the page has more than one |
replace |
true replaces the whole selection; the default merges. { replace: true } with nothing else is a reset |
Everything goes through the same rule engine as a click, so a value a rule
forbids is corrected exactly as it would be in the built-in UI, and
configurator:change fires afterwards with the settled result.
An event that arrives before the element has hydrated is replayed, not dropped. A lazily-hydrated configurator being driven by a theme's own controls is precisely the case this API exists for, so losing that first event would make it unreliable where it matters most.
Non-string values are ignored rather than coerced, and an empty merge does nothing — a stray event from something else on the page must not clear a shopper's selection.
Layer roles
role is a vocabulary for whoever authors the art, and it supplies the default
blend mode. blend remains available as an escape hatch.
| role | default blend | behaviour |
|---|---|---|
art |
source-over |
Finished artwork, drawn as-is. Tints are ignored. |
mask |
source-over |
Tintable silhouette — only the alpha matters. |
shade |
multiply |
Grayscale shadow map. Should be clipped. |
light |
screen |
Grayscale highlight map. Should be clipped. |
decal |
source-over |
Placed by rect (ratios of the stage) rather than filling it. |
text |
source-over |
Rendered type. No src; content comes from a text group or setText. |
shade and light are separate roles on purpose. Canvas composites in
non-linear sRGB, so multiply darkens far harder than real light does: one
mid-grey map multiplied over a tint turns a vivid red into brick. Splitting
shadow (multiply) from highlight (screen), and authoring the shade map
white-biased — near-white everywhere the surface is lit — is what keeps
saturation. opacity on the shade layer is the merchant-facing dial for it.
clipTo confines a layer to another's alpha. Required on shade/light:
multiply and screen both paint the whole rect, so an unclipped map bleeds across
everything behind the product. parseConfig warns when one is missing.
Recolouring type — setTextStyle
{
"id": "gold",
"label": "Old gold",
"swatch": "#c8a03c",
"effects": [{ "type": "setTextStyle", "layer": "stitch", "color": "#c8a03c" }],
}
One hex value per option is the whole thing. tint cannot do this — it applies
only to mask and decal layers, and the parser warns and points here if you
try it on type.
| field | |
|---|---|
color |
Body colour. Empty leaves the layer's own alone, like tint. |
highlight, shadow |
Optional. null falls through to the layer, then to derivation. |
strokeColor |
Optional outline override. |
The relief tones follow the colour automatically. relief.highlight and
relief.shadow omitted means derive from the body colour — not white and
black. That is what lets one swatch carry one hex: an author cannot pin tones
for a colour the shopper picks at runtime, and white-on-black relief reads as
moulded plastic rather than thread or engraved metal.
Precedence for every colour is effect → layer → derived. Pin a tone on the layer to opt out.
Two things it deliberately does not do:
- A gradient fill is left intact. Chrome and gold are designed ramps with a
sharp light/dark horizon, and one swatch colour cannot meaningfully rewrite
one. Use a
solidfill on any layer a swatch should recolour. - It does not change the font. A family swap changes the metrics, so auto-fit would have to re-run and an approved layout can overflow.
Effects — the v1 set
Every option declares what it does to the render. A small set, deliberately:
| type | fields |
|---|---|
tint |
layer, color (hex), mode (flat|multiply|overlay), strength |
setImage |
layer, src |
setVisible |
layers[], visible |
setOpacity |
layer, opacity |
setText |
layer, text — fixed wording, or "" to clear a text layer |
setTextStyle |
layer, color, optional highlight/shadow/strokeColor — see below |
tint and setTextStyle split by layer role and do not overlap: tint
recolours a mask/decal, setTextStyle recolours text. Using the wrong one
warns or errors rather than silently doing nothing.
Effects apply in group order, so when two selected options touch the same layer the later group wins.
Unknown effect types are a hard error, not a silent skip — a config authored against a newer build should say so rather than quietly rendering the wrong product.
Text and engraving
Personalisation has three parts: a font, a text layer that draws it, and a text group the shopper types into.
"fonts": [
{ "family": "Engraver", "src": "engraver.woff2", "weight": 400, "style": "normal" }
],
"layers": [
{
"id": "engraving",
"role": "text", // no `src` — this draws glyphs
"rect": { "x": 0.15, "y": 0.38, "w": 0.7, "h": 0.24 }, // the box type is fitted into
"textStyle": {
"font": "Engraver", // a fonts[] family, or a generic CSS family
"size": 0.16, // FRACTION of stage height, like rect
"minSize": 0.06, // auto-fit floor — still legible when engraved
"color": "#3d3d3d",
"align": "center",
"letterSpacing": 0.08, // fraction of the font size
"transform": "uppercase",
"strokeColor": null, // optional outline, for light type on light art
"strokeWidth": 0
}
}
],
"groups": [
{
"kind": "text", // ← not an option group
"id": "engraving",
"label": "Engraving",
"required": false, // default; a required engraving blocks the sale
"text": {
"maxLength": 20,
"placeholder": "Your name",
"default": "",
"bindTo": ["engraving"], // the text layer(s) this renders into
"allow": "A-Za-z0-9 .'-" // character class; empty = anything printable
}
}
]
kind defaults to "options", so every config written before text groups
existed parses unchanged.
Font sources
fonts[].kind is either file (a woff2/otf/ttf, loaded with FontFace) or
stylesheet (a CSS URL whose @font-face rules point at the real files — what
Google Fonts gives you). parseConfig infers it from the URL, so a merchant
can paste a Google link and it works:
"fonts": [
// Google Fonts — kind and family are both read from the URL
{ "src": "https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400" },
// Self-hosted — resolved against assets.baseUrl
{ "family": "Engraver", "src": "engraver.woff2", "weight": 400 }
]
A stylesheet cannot go through FontFace, which expects font binary — it
gets a <link> in document.head instead, and the family is then confirmed with
document.fonts.load(). That check has to come after the stylesheet parses:
asked earlier it resolves to an empty list, which is indistinguishable from
"family not found".
The link goes in the document head, not the shadow root: @font-face rules
inside a shadow tree do not register with the document's font set, and canvas
fillText resolves families against the document.
The family name is read out of a Google URL rather than retyped, because it has to match the stylesheet exactly and a near-miss renders a fallback face with no error anywhere.
Hotlinking Google Fonts is a GDPR problem for EU stores. It sends every
shopper's IP to Google on every product page, which German courts have ruled a
breach without consent. parseConfig warns, and the editor says so next to the
field. Download the woff2 and host it in Shopify Files to avoid it — fonts do
not taint the canvas, so this is a privacy and reliability decision, not an
export one.
Fonts are loaded and awaited before the first paint. fillText does not wait
for a font: if the face isn't ready the browser silently substitutes a fallback
with different metrics and draws that. Auto-fit then sizes against the wrong
glyphs, and an export taken from that frame is not what the customer approved.
Only the families a render actually uses are fetched, so a config offering five
typefaces still downloads one.
Auto-fit shrinks, never wraps. The type starts at size and binary-searches
down to minSize. Single line only — multi-line wrapping is not in this version.
Text effects — relief and fill
Two independent parts of textStyle, because "gold and embossed" is a normal
combination and a single union would force a false choice:
"relief": {
"kind": "deboss", // none | emboss (raised) | deboss (carved/engraved)
"depth": 0.035, // offset of the two passes, fraction of font size
"angle": 0, // direction the light comes FROM; 0 = above
"softness": 0.04, // blur on the relief passes; 0 = hard bevel
"highlight": "#ffffff",
"shadow": "#000000"
},
"fill": {
"kind": "gradient", // solid | gradient
"angle": 0, // 0 = top to bottom
"stops": [{ "at": 0, "color": "#6b4a12" }, { "at": 0.3, "color": "#f7e9a8" }]
}
Relief is offset copies of the glyph drawn under the body fill — shadow, then
highlight, then the fill. emboss puts the highlight on the side facing the
light; deboss is the same two passes swapped, which is what reads as cut into
the material. For an engraving, deboss is the physically honest choice and
usually what a merchant actually means.
Chrome and gold are gradients, not a rendering mode. The characteristic look
comes from a sharp dark→light transition at the "horizon" (around at: 0.5) —
the reflected boundary between sky and ground. Without it a grey gradient just
looks like grey type. The editor ships presets for chrome, gold and rose gold
that write ordinary stops, so anything remains adjustable afterwards.
The gradient is built around each run's own centre, so curved text shows the same banding on every letter rather than one gradient smeared across the arc.
Both apply to straight and curved text — they share one glyph painter, so an effect can never work on one and silently not the other.
There is no canvas primitive for a bevel. The alternatives were considered and
rejected: SVG feSpecularLighting via ctx.filter = url(#…) is the flakiest
corner of ctx.filter and SVG-as-image cannot load external fonts; a WebGL
filter stack (pixi-filters) is larger than this entire widget.
Curved text
textStyle.path is either { "type": "straight" } (the default, placed by the
layer's rect) or an arc:
"path": {
"type": "arc",
"cx": 0.5, // circle centre, fraction of stage width / height
"cy": 1.02, // NOT clamped to the stage — see below
"radius": 0.38, // fraction of stage WIDTH
"angle": 0, // degrees; 0 = 12 o'clock, positive = clockwise
"side": "outside", // "inside" flips glyphs for the bottom of a circle
"maxSweep": 70 // auto-fit shrinks until the run fits this angle
}
The centre is deliberately unclamped and the radius may exceed 1. The commonest curved layout — a varsity arc across a chest — has its circle centre below the garment, usually off-canvas, with a radius larger than the stage. Only the visible arc has to be on the stage; the geometry generating it does not.
Radius is a fraction of width on both axes, not per-axis. Scaling it per-axis would make the path an ellipse on any non-square stage, and glyphs around an ellipse need variable rotation to stay tangent. Anchoring to one dimension keeps it a true circle at every screen size, because the canvas always preserves the stage's aspect ratio exactly.
Auto-fit is angular, not box-based — a curved layout is bounded by how much
of the circle it may occupy, so maxSweep is what the search shrinks against.
align does not apply; the run is always centred on angle.
Kerning is lost on an arc. There is no canvas primitive for type on a path,
so each glyph is placed and rotated individually — and fillText can only kern
within a single call. Tracking usually hides it; increase letterSpacing rather
than fighting it.
A text group's value is the typed string, held in the same Selection map as
option ids. That is what lets rules, cart properties and the payload work on it
with no extra plumbing:
"showIf": [{ "group": "engraving", "notIn": [""] }] // "only once something is engraved"
Everything typed is sanitised on every resolve, not just on input — so a
value from a URL, a restored cart payload or an API call gets the same treatment.
Control characters are stripped, whitespace runs collapse (a shopper padding a
name to centre it would otherwise get a preview that doesn't match what is cut),
the allow class is enforced, and the result is capped at maxLength.
maxLength is a manufacturing constraint, not just a data one. The value
rides in the _config property and has to stay legible inside its box. Hard
ceiling of 200.
Personalised lines do not merge, and should not: Shopify merges lines with identical properties, and two different engravings are genuinely different products.
Rules
"showIf": [
{ "group": "fabric", "in": ["cotton", "linen"] },
{ "group": "size", "notIn": ["xs"] }
]
An array is an AND; the values inside a clause are an OR. Usable on a
group or on a single option. No expression strings and no eval anywhere —
storefronts increasingly ship a CSP, and a declarative predicate is the only form
the editor can render as a UI.
A clause referencing a group with no current selection always fails, for
notIn as much as for in. The alternative makes visibility depend on
evaluation order and produces options that flicker while a cascade settles.
A hidden group's selection is dropped. This is the nastiest bug in this class
of product: a rule hides a group, its selection stays live, and the shopper is
shipped an option they could not see and did not choose. Because dropping one
selection can change what else is visible, resolve() iterates to a fixpoint
(max 10 passes) rather than resolving once. resolve() also remembers the
caller's original request across passes, so a deep link or a restored cart
payload isn't replaced by defaults just because a dependency hadn't resolved yet.
Cycles (A shown only if B, B shown only if A) are rejected at parse time.
Pricing
pricing.mode:
none(default) —priceDeltais metadata; the component renders no price UI at all. Line item properties cannot change what Shopify charges, and showing a delta the checkout doesn't apply is a chargeback and a TOS problem.variant— priced groups map to real product options viavariantBinding, so real variants carry the price.function— a Cart Transform Function applies deltas server-side. Needs an app.
priceDelta is an integer in minor units (cents).
Cart output
toCartSummary returns { properties, payload, valid, missing, priceDelta }.
The theme mirrors properties into hidden inputs and nothing else — the naming,
omitWhen and empty-value rules all live here where they are tested.
The payload is ids only (~100 bytes) and contains no timestamp or nonce: Shopify merges cart lines with an identical variant and identical properties, and two shoppers buying the same configuration should merge into quantity 2.
Required vs optional
Required: schemaVersion, id, stage.width, stage.height, at least one
layer, layers[].id, layers[].src, groups[].id, at least one option per
group, options[].id.
Everything else defaults. parseConfig returns a fully-normalized document,
so consumers never re-derive anything: default is resolved to a concrete option
id, blend is derived from role, cart.properties is generated one-per-group
from group labels, and relative src values are absolute.
Versioning
schemaVersion is an integer bumped only on a breaking change. Adding an
optional field, an effect type, a control or a role does not bump it.
A config from the future is a hard error with an actionable message ("upgrade the
visualizer bundle in the theme") rather than a half-render. When version 2 exists,
migrations go in packages/schema as an ordered list of pure (doc) => doc
steps run before validation, so a stored metafield never has to be re-saved — and
packages/schema/test/ gains a frozen fixture per historical version, which is
the only thing that keeps migrations honest.
Asset rules
data:URIs are rejected. A 200KB PNG is ~270KB of base64, and the whole document must fit in a 65,535-byte metafield.blob:URLs warn. Editor-preview only; dead once exported.- Non-Shopify hosts warn. Without CORS headers they taint the canvas and
toBlob()throwsSecurityError— at add-to-cart time, on a live store. The component also probes each origin at load time so the failure is loud in development.