Skip to content

The configurator config contract

The single source of truth exchanged between the editor that produces the JSON and the component that renders it. Every field, and which ones are required.

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 solid fill 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/strokeColorsee 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) — priceDelta is 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 via variantBinding, 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() throws SecurityError — 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.