Skip to content

Theme integration

Optical Form works on any Online Store 2.0 theme — the ones that let you add blocks from the theme editor — and needs no developer to install. It is verified on Dawn and Horizon; for custom themes there are adapters and the hooks below. This page summarizes the integration layer for anyone who wants to customize it. For the block and embed visual settings, see Button & cart (theme).

The drawer and trigger are standalone web components that are not re-rendered on variant change. So Optical Form resolves the current variant lazily (at open and at add-to-cart), in this priority order:

  1. Merchant hook: window.OpticalFormHooks.getVariantId(triggerEl)
  2. Configurable block selector (the Variant input selector setting)
  3. Standard product form input [name="id"] (Dawn/Horizon and most themes)
  4. URL parameter ?variant=
  5. Fallback to the server-rendered value
window.OpticalFormHooks = {
// Return the selected variant id (highest priority).
getVariantId(triggerEl) { return /* variant id */; },
// Optional: override the price (in cents) for a variant.
getVariantPrice(variantId, triggerEl) { return undefined; },
// Optional: turn off cart bundle grouping.
disableCartGrouping: false,
};

Add-ons and accessories are added as separate line items linked via parent_id (Shopify’s native bundle — needed for cascade-remove, checkout and order emails). Since themes render them as flat rows, CartBridge reorders them so each child sits directly under its parent line, and tags them ofd-bundle-parent / ofd-bundle-child.

That’s where it stops: it does not hide the children’s quantity or remove controls, and the app ships no cart CSS at all. Hiding a control never prevented the action — the line can still be changed and removed through the API — and that same row is shared by other apps (warranties, shipping insurance), so hiding their remove button would strand the shopper with a third party’s charge. Keeping the order coherent is the server’s job, not CSS’s.

To fine-tune how it looks there are two levers on the app embed: the Compact the add-on lines in the cart checkbox (cart_tidy_addons, off by default) and the Custom CSS field. See Design & cart.

Cart selectors and the after-add behavior are set in the app embed — see Button & cart. CartBridge auto-detects most themes’ patterns; you only need to override selectors on non-standard themes.

The hooks above point inwards: the theme answers something the app asks. These events go the other way — the app tells the page what the shopper is doing, so your analytics, your chat widget or your abandoned-cart flow can live where they already live, without needing a feature from us.

You don’t have to do anything to get them. If nobody listens, nothing happens. What to build on top of each one, with examples for chat, analytics and abandonment: What to do with each event.

document.addEventListener('opticalform:step:rendered', (e) => {
gtag('event', 'lens_configurator_step', {
step_type: e.detail.stepType,
step_index: e.detail.stepIndex,
session_id: e.detail.sessionId,
});
});

They are dispatched on <optical-form-drawer> and bubble, so you can listen on the element or on document.

Event When Own fields
opticalform:open the configurator opens
opticalform:step:rendered the shopper lands on a step isFirst, isLast
opticalform:validation:error a step stops them moving on
opticalform:addtocart:start the add begins
opticalform:addtocart:success the cart accepted it lineCount, totalPrice, currency
opticalform:addtocart:error it failed kind
opticalform:close it closes completed
optical-form:cart:orphan a frame in the cart is missing one of its child lines: the customer removed the lenses from the theme’s cart groupId, key, expected, actual

The last one is not emitted by the configurator but by the cart bridge, on document, as soon as it spots a frame without the lenses or treatments it left with: the row also gets the class ofd-bundle-orphan and a translated notice underneath. The app deletes nothing and never blocks checkout — the event is there so you decide (ping your chat, remove the frame, whatever fits). While the bridge rearranges the cart after an add, the checkout buttons are disabled for a moment and <html> carries the class ofd-cart-busy. Both can be switched off in the bridge configuration: orphanNotice: false and guardCheckoutWhileBusy: false.

They all carry the same context:

{
version: 1,
sessionId, // random, per drawer session — stitches one visit together
productId, variantId,
stepIndex, stepId, stepType,
totalSteps, // steps CONFIGURED, not the ones this shopper will walk
}

kind is 'throttled' | 'unavailable' | 'unknown': the cause, not the message — the message is a label the merchant can rewrite. Only throttled is worth retrying.

completed tells “they finished” from “they left”, which is the whole difference when you’re recovering an abandonment.

totalPrice is in cents, and only on :addtocart:success.

They say WHERE the shopper is, never WHAT they answered. A CustomEvent is readable by every script on the page, so the payload is a closed list: it never contains the prescription values, the uploaded file or its name, the photo of the prescription, the merchant’s own text or the step’s DOM node.

Two things worth knowing before you build on this:

  • stepType is inferred health data. Reaching the prescription step means that person has a refractive error. It’s included because without it the events are useless, and because the same inference is already within reach of any script on the page. Handle it accordingly.
  • totalPrice only after the add. Once added, that figure is in /cart.js for anyone to read. Before the add it would be new information, and in a contact-lens form it would reveal a single-eye order.

None of them is cancelable: preventDefault() does nothing. That’s deliberate. A/B tests and merchant-side validation rules will need a different mechanism, and it will be added as one rather than by changing what these mean.

version only goes up if an existing field changes shape. Adding new fields doesn’t bump it: a listener reading what it already knew keeps working. Events are never renamed or removed without a major version change.

:step:rendered fires when the step changes, not on every repaint: attaching a file, the contact-lens catalogue narrowing a combination, or mutually-exclusive options all redraw the panel without moving the shopper. If you need to know when new HTML lands — a translation layer, say — use a MutationObserver on the panel.

The technical detail (resolver, CartBridge, selectors, events) lives in INTEGRATION.md inside the app extension: extensions/optical-form-ext/INTEGRATION.md.