Shopify Builds>Nudestix>On-model shade carousel bound two ways to the Dawn variant picker

On-Model Shade Carousel Bound Two Ways to the Dawn Variant Picker

A Flickity carousel of one on-model shot per shade, rendered below the product gallery from a variant metafield; each cell carries the id of its Dawn variant radio, a tap on a face resolves to a click on that radio, a click on a radio moves the carousel — and the carousel itself never touches the product form.

Shopify's option model has no notion of a shade image; Dawn's picker shows a chip per value. This carousel renders one on-model shot per shade from a variant metafield and binds it two ways to Dawn's variant radios: a tap on a face resolves to a click on the radio it names, and a radio click moves the carousel. Nothing writes to the product form, so price, media and availability stay Dawn's to update.

The Problem

A Shopify option value is a string. A variant can carry one featured image, and on a color cosmetics catalog that image is usually the packshot — the stick, not the cheek. Dawn's variant picker renders one control per option value and nothing else, so a product with thirteen shades shows thirteen chips and no way to see any of them on a face.

Nudestix sells color where the decision is "which shade suits me," not "which SKU." Shoppers needed to see each shade on a model, beside the picker, and browse them the way they'd browse a lookbook. And whatever they picked in that carousel had to be exactly what the add-to-cart form submitted — a second picker that disagrees with the first is worse than no second picker.

The Constraint

The product form on this theme is Dawn 6.0.2's: a variant-radios element whose checked radio decides the variant id, the price, availability, the gallery slide and the URL. Anything visual had to end up as a click on one of those radios, or the carousel and the form would drift apart. The binding also had to run the other way — a shopper who uses the native swatch row should see the carousel follow.

The carousel could only appear where a merchandiser had switched it on and the product had enough shades to justify it, and it had to render nothing, and break nothing, everywhere else; the buy box is the page, and a script error beside it isn't acceptable. Flickity measures cell widths at initialization, so it has to be handed markup that's already laid out. And the option isn't always called the same thing: some products name it Color, others Colors.

What We Built

shades-slider.liquid, rendered from main-product.liquid, main-product-bundle.liquid and main-single-product.liquid, roughly 440 lines of Liquid, markup and inline script.

The Liquid pass. The snippet walks product.options_with_values and matches the option whose downcased name is color or colors. For each shade it renders, it emits one .shades-carousel-cell inside #faces-crousel: a <picture> of the variant's custom.face_image metafield at 360px and 535px widths, falling back to variant.featured_image; a caption; and a hidden input whose data-id is {{ section.id }}-{{ option.position }}-{{ index }}-{{ product.id }} — the same four parts, in the same order, that Dawn uses for the id of that value's radio input. The cell whose value equals option.selected_value also gets is-clicked, so a variant chosen through the URL opens the carousel on its own face.

The forward binding. createShadesSlider builds a Flickity instance on the carousel with wrapAround, no page dots and initialIndex: '.is-clicked', then reads every [data-variant-radios] [data-variant-radio] on the page into a variantRadiosMap keyed by radio id. On Flickity's staticClick — a tap that wasn't a drag — the tap resolves to a click on the radio the cell names, through the data-id the two share. From there Dawn's own variant-radios element does what it always does: re-renders the price, availability and media and updates the URL.

The reverse binding. Every variant radio gets a click listener. handleCellChange finds the cell whose data-id equals the radio's id and calls flkty.selectCell() on it, so choosing a shade in the native swatch row scrolls the carousel to the matching face.

LIQUID · ONE RENDERBROWSER · SHADES-SLIDER SCRIPTBROWSER · DAWN, UNTOUCHED Carousel cell, one per rendered shade custom.face_image · hidden input data-id is-clicked on the selected value Dawn variant radio, one per value input[type=radio] id · data-variant-radio checked on the selected value Flickity carousel staticClick resolves to a radio click radio click → flkty.selectCell() variant-radios price · availability · media · URL re-rendered by Dawn on change Product form · add to cart shared id section·option·index·product cells become Flickity slides radios read into variantRadiosMap resolves to a radio click follows the checked radio Dawn only no arrow from the carousel to the form or the cart
Reconstructed from the code. The carousel and Dawn's picker share nothing but an id string built from the same four parts in two files; every state change crosses through a real radio click, and the product form is driven by Dawn alone.

The guard. The whole initialization is wrapped in a try/catch that logs ERROR: SHADES_SLIDER rather than throwing, and createShadesSlider throws early if the carousel element or the radio list is missing. A product that renders the snippet without a usable color option loses the carousel and keeps its buy box.

Why This Way

Driving the native radio instead of the product form or the Cart AJAX API keeps every downstream Dawn behavior — price, availability, media switching, form serialization, the variant in the URL — working exactly as the theme wrote it. The carousel is a presentation layer over the real picker, not a parallel state machine that has to be kept in step with one.

The join is deliberately dumb: an id string built from section.id, the option position, the value index and the product id, computed in Liquid the same way Dawn computes it. No registry, no JSON payload. What that buys in simplicity it charges in coupling: the two ids are assembled from the same four parts in two different files, and if the radio's id format ever changes, the map lookup returns nothing and a tap on a face does nothing, with no error to point at the cause.

Flickity is a second carousel library on a page that already carries Dawn's slider component. It's enqueued only when the carousel is, and self-hosted, but it's still weight, and every shade loads a face image at up to 535 pixels wide — a real cost on a thirteen-shade product.

Why Not an App

Shade and swatch apps, and Shopify's own swatch picker, render their own control and typically own the add-to-cart along with it. What this storefront needed was different in kind: per-variant on-model photography, authored by the merchandiser as a metafield, presented as a browsable carousel layered over a picker that already existed and already drove three other features on the page.

A bought picker would have meant either giving up the native radios the rest of the page depends on, or running two pickers and reconciling them. Rendering from a metafield keeps the photography in the product data, where it can be exported, bulk-edited and reused, and keeps the binding to the form as one line of JavaScript rather than an app's event bridge. On a theme based on Dawn 6.0.2, which predates native swatch support, there was also no platform picker to lean on.

Implementation Notes

  • The carousel is mounted only when custom.display_shades_carousel is set on the product and product.variants.size >= 5, on products with more than a default variant, outside quick view; the carousel CSS, Flickity's CSS and its script are enqueued inside the same condition.
  • Flickity ships as flickity.pkgd.min.js in the theme's assets rather than from a CDN, so the carousel's availability doesn't depend on a third-party host.
  • The is-clicked class is written in Liquid on the cell whose value equals option.selected_value, and Flickity's initialIndex is that selector, so the carousel opens on the current variant rather than the first cell.
  • variantRadiosMap is built once at initialization from the radios present in the DOM; both bindings resolve through it, so there's one place where a cell id becomes a radio.
  • Each face is a <picture> with three source breakpoints — 360px below 768px, 535px above — with loading="lazy" on the img, and width and height taken from the variant's featured image so the cell has dimensions before the face loads.
  • The single-product section passes current_product where the other two pass product, so one snippet serves three product sections without a branch.

Edge Cases

  • A product with no option named color or colors renders no cells; createShadesSlider then throws "Carousel or variant radios are missing," and the caller's catch logs it and moves on.
  • A variant with no custom.face_image falls back to variant.featured_image, so a shade whose model shot hasn't been uploaded shows its packshot instead of an empty cell.
  • A page that renders the snippet without the radio list — no [data-variant-radios] in the DOM — hits the same early throw and leaves the rest of the product page intact.
  • The is-clicked cell is chosen from option.selected_value, so a URL that lands on a specific variant opens the carousel on that shade.
  • staticClick fires only for a tap that Flickity didn't interpret as a drag, so swiping through faces doesn't change the variant.

Platform Primitives Used

  • Metafieldscustom.face_image on the variant is the carousel's content, and custom.display_shades_carousel on the product is the switch that mounts it.
  • Liquid section schema and settings — the snippet renders inside the product section, whose section.id is the first part of the id contract that joins a cell to its radio.

Integrations in Play

  • Flickity — the carousel engine, self-hosted in the theme's assets and enqueued only on products that mount the carousel.

Where It Runs

On the product detail page, through three templates: the standard product template, product.bundle and product.single-pdp, each of whose product section renders the snippet below the media gallery. Quick view is excluded explicitly. On any product the carousel appears only where the merchandiser has set the display metafield and the product carries at least five variants.

What This Demonstrates

How We Know

One snippet from the theme, roughly 440 lines, read alongside the three product sections that render it and the gate they share. Documented from the code; no client-side record of the decision survives. A live check on 2026-09-07 of several product pages found the carousel rendering with Flickity loaded and every face cell's data-id matching a variant radio id on the same page. There's no brief explaining why the binding was written against the radios rather than the form; the code makes that choice consistently, and this page reports the mechanism without narrating an intent it can't evidence.

Related Builds

The Buy-vs-Build Question

The decision here is where shade photography lives — in a variant metafield the storefront reads, or in an app's mirror of the catalog — and who owns the picker it's bound to. Both halves of that argument, and what each costs to maintain, are set out in metafields and metaobjects architecture: buy or build? and in swatches and visual options: buy or build?.

Provenance & Evidence

  • Client: Nudestix — nudestix.com
  • Surface: Product detail page
  • Templates served: three — product, product.bundle, product.single-pdp
  • Complexity: High — one snippet carrying the render, the binding in both directions and the guard
  • Attribution: Deploi-authored. The snippet, its Liquid pass and its binding script are ours, and so is the swatch row it binds to — those radios carry our data attributes and our colour styling, and are documented as their own build. What is Dawn 6.0.2's is the variant-radios element behind them and the product form it re-renders. Flickity is a third-party library.
  • Status: Live, verified 2026-09-07
  • Evidence: One snippet and the three product sections that render it, plus a live check of the storefront
  • Confidence: Moderate — documented from the code; no client-side record of the decision survives
  • Primary capability: Metafield-driven PDP content blocks

Ready to Show the Shade on a Face, Not a Chip?

If your shoppers are picking a color from a row of hex squares, the photography that would sell it probably already exists. Contact us today and we'll show you what it takes to put it on the product page, bound to the picker you already have.

More builds