Shopify Builds>Nudestix>Grouped shade swatch picker with shade-finder dialog and GA4 shade-finder events

Grouped Shade Swatch Picker With Shade-Finder Dialog and GA4 Shade-Finder Events

Finish became a variant metafield instead of a second product option, which is what lets forty shades group into Matte, Matte Lux and Bloom without touching the SKU matrix — and the shade quiz beside them is bought, wrapped in a native dialog and measured on our side of the boundary.

Finish is not an option dimension on this catalog, and the shade quiz is a bought product. We grouped swatches by a finish metafield rather than splitting the variant matrix, put the quiz inside a native dialog element gated on a product metafield, and checked the origin of its messages before turning them into a GA4 shade-finder event.

Fact Strip

  • Client: Nudestix — color cosmetics DTC, nudestix.com
  • Surface: Product detail page
  • Templates served: One — product.single-pdp
  • Complexity: High — roughly 700 lines in one section
  • Attribution: Deploi-authored. The finish grouping, the dialog, the measurement bridge and the badge decoration are ours, written inside a theme based on Dawn 6.0.2, which we did not author. The shade questionnaire inside the dialog is a third-party product, SkinMatch, and we did not build it.
  • Status: Live — verified 2026-09-06
  • Evidence: Moderate — documented from the code; no client-side record of the decision survives
  • Platform primitives: 3

The Problem

One lipstick or blush product here can carry dozens of shades, and those shades belong to finish families — Matte, Matte Lux, Bloom — that a shopper compares within rather than across. Someone deciding between mattes is not also weighing a Bloom, and a flat row of forty radio inputs asks them to do exactly that.

That is the presentation problem. The decision problem underneath it is bigger. Choosing a shade for your own skin from a screen is the single largest hesitation in color cosmetics, and no amount of accurate swatch rendering answers "which of these is mine." A picker can only show; something else has to recommend.

The Constraint

Three constraints set the shape, and the first one is the interesting one.

Finish is not an option. In Shopify's data model, the axes a product varies on are its options, and every combination of option values is a variant. Making finish a second option would multiply the matrix — every shade crossed with every finish, most of those combinations meaningless — and it would rewrite the option structure of products whose SKUs, inventory and existing links already assume one axis. The grouping had to come from somewhere that isn't the option model.

Option values carry no color. They are plain strings in Liquid, and Dawn 6.0.2 predates Shopify's native swatch support, so there is no color attached to a shade name anywhere in the product data. That gap and the shade-to-hex registry built to close it are the subject of a separate page; this section consumes the same registry rather than reinventing it.

The recommendation engine is somebody else's. The shade questionnaire is a bought product, hosted by the vendor and rendered inside our page. It is not code we can instrument from the inside. Anything we want to know about how shoppers use it has to be learned from what it chooses to tell us, across an origin boundary.

What We Built

Grouping. Inside the variant_picker block, the section walks product.options_with_values. For the Color option, it pre-scans the variants before rendering anything, reading variant.metafields.my_fields.stick-type and collecting the shades into per-finish lists — matteColors, matteLuxColors, bloomColors — along with a uniq'd shadetypes array holding the finishes actually present on this product. If more than one finish is present, the swatches render as labeled groups. If only one is, they render as a single fieldset and no grouping UI appears at all. A product's presentation follows from its data rather than from a setting somebody has to remember to turn on.

Swatch state. Each swatch is decorated from variant metafields: custom.new_badge renders a NEW pill, custom.restocked renders a RESTOCKED ribbon, and custom.is_special_variant drives special styling. The NEW badge carries adjacency logic — the section inspects the neighboring entries in the variant list and switches to reduced padding when two consecutive swatches would both wear a pill, so the badges sit beside each other instead of overlapping. The option label itself switches from "Shade:" to "Color:" for the magnetic eye color range, which is not merchandised as shades.

The dialog. A Shade Finder button appears beside the picker only on products that carry the shade-finder metafield. It opens a native <dialog> element whose width, height and border radius are section settings per breakpoint, so the modal is sized in the theme editor rather than in a stylesheet. Backdrop dismissal is handled by comparing the pointer coordinates of a click against the dialog's own getBoundingClientRect() — a click inside the box closes nothing, a click outside it closes the dialog — because a <dialog>'s backdrop is not a separate element you can bind to. The vendor's questionnaire renders inside.

The measurement bridge. This is the part that exists because the quiz is bought. A window message listener, receive_SMT_message, is the only channel between the questionnaire and the storefront. It validates e.origin against both getskinmatch.com and www.getskinmatch.com before touching the payload — origin checking first, parsing second, because a message listener with no origin test accepts anything any frame on the page cares to send it. From a valid message it reads the first EAN key of the results object, resolves that to a product, and pushes a shade_finder_interaction event into the data layer carrying the action, the matched product title, and a full GA4 ecommerce item assembled from the selected variant — with prices converted out of cents through a roundToDecimalPlaces helper rather than string surgery on a formatted number.

Why This Way

Putting finish in a variant metafield keeps the SKU matrix flat and keeps merchandising in the admin. A shade moves between finish groups by editing a field on a variant; a finish is added by using a new value. Neither one republishes a variant, changes an option structure or invalidates a URL. The alternative — a second option — would have bought the same grouping at the price of an option axis the catalog can never take back.

The native <dialog> is the smallest thing that does the job. Focus trapping, Esc handling and the top-layer stacking that keeps a modal above a sticky buy bar all come from the element, so the page carries no modal library for one button. What the element does not give you is backdrop dismissal, which is why the coordinate comparison exists.

Two costs came with the shape. The grouping is a second pass over the variant list before the picker renders — every product with a Color option pays a full pre-scan whether or not it turns out to have more than one finish, and that scan is Liquid work on a page that already does a lot of it. And the measurement is downstream of a message we do not author. We read the first EAN in the results payload; if the vendor changes the shape of what it posts, our event changes shape with it, silently, and the first sign is a report that stops making sense rather than an error anyone sees.

Why Not an App

Two different answers, because this build sits on both sides of the buy-versus-build line at once. That is worth being precise about, because the honest version of this decision is almost never "build everything."

The quiz is bought. Shade matching is a real recommendation problem with real work behind it — a model, a shade library, a maintained matching engine. Nobody should write that to sell lipstick. So the questionnaire is SkinMatch's product, and this build is the mounting and the measurement around it, not the thing itself. What we built is the boundary: where it appears, what it appears inside, which messages from it are trusted, and what the business learns from a shopper using it.

The swatch layer stays in the theme. The reasoning there is about reuse rather than about capability. The same color list already feeds the bundle picker and the card swatches on other surfaces, so a swatch app would have introduced a second source for a value the storefront resolves in three places. It would also have meant a second pass over every variant — the app's, after ours — to produce a picker the page had already rendered.

Add the finish argument to that. A swatch app groups by option, because option is what the platform gives it. Grouping by a metafield is a decision about your own data model, and it is not a decision a component installed onto the page can make for you. Buying the swatch would have meant either giving up the finish grouping or maintaining it twice.

Implementation Notes

  • Finish groups are assembled in a pre-scan of the variant list before the picker renders, producing per-finish shade lists plus a uniq'd array of the finishes actually present on the product.
  • Grouped rendering is conditional on that array holding more than one entry, so a single-finish product renders one plain fieldset.
  • The NEW badge inspects the neighboring entries in the variant list to decide spacing, switching to reduced padding when two consecutive swatches both carry one.
  • The option label switches from "Shade:" to "Color:" when the product handle identifies the magnetic eye color range.
  • The Shade Finder button and its dialog are emitted only when the product carries the shade-finder metafield, so products without a quiz render neither.
  • Dialog width, height and border radius are section settings declared per breakpoint, so the modal is sized in the theme editor.
  • receive_SMT_message validates the message origin against both the apex and www forms of the vendor host before reading the payload, and takes only the first EAN key of the results object.
  • GA4 item prices are converted from cents with a roundToDecimalPlaces helper rather than string manipulation on a formatted price.

Edge Cases

  • Unavailable option values render as a disabled swatch rather than being hidden, so the shade is still visible as part of the range.
  • Products whose Color option has a single finish skip the grouping UI entirely.
  • The quick-view template renders a reduced "Shades" label variant of the same picker.
  • dialog.addEventListener is wrapped in try/catch, so a template that renders the picker without the dialog does not throw.
  • If the vendor's action is not the matching-results one, the event still fires — with an empty result title rather than a missing event, so the funnel step is recorded either way.

Platform Primitives Used

  • Metafields — finish type, the badge flags and the shade-finder gate are all variant and product metafields, read at render time.
  • Liquid section schema and settings — the dialog's per-breakpoint dimensions and the shade palette are theme and section settings, so both are editable without a deploy.
  • dataLayer — the shade-finder event is pushed into the data layer as a GA4 ecommerce item built from the selected variant.

Where It Runs

On the product detail page, through one template: product.single-pdp. The picker also renders in a reduced form in quick view, where the label shortens and the grouping collapses.

What This Demonstrates

Primary: guided selling: quizzes, finders and routine builders — specifically its integration form, where the engine is bought and the funnel step is instrumented on our side of the origin boundary.

It is also a variant swatches and shade pickers build, and it is the one in the corpus where the swatch problem is a data-modeling problem: the grouping axis had to go somewhere other than the option model.

How We Know

One product section, roughly 700 lines, read in the theme, along with the metafield definitions and the theme setting it depends on. Documented from the code; no client-side record of the decision survives. There is no brief here explaining why finish went into a metafield instead of a second option, or why the dialog was written against the native element rather than the theme's modal component. The code shows both choices being made consistently — a pre-scan that only makes sense if finish is not an option, an origin check written before the payload is read — and this page reports the mechanisms and declines to narrate intent it cannot evidence.

Related Builds

The Buy-vs-Build Question

The grouping decision here is a variant-model decision before it is a UI one: what belongs on the option axis, what belongs in a metafield, and what that costs you later. That is the argument, with the platform limits that force it, in variant limits and complex options: buy or build?.

Ready to Help Shoppers Find Their Shade?

If your color page shows every shade at once and helps with none of them, the fix is part data model and part measurement — and neither half is a plugin. Contact us today to talk about what shade guidance would look like on your storefront.

More builds