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.
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_carouselis set on the product andproduct.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.jsin the theme's assets rather than from a CDN, so the carousel's availability doesn't depend on a third-party host. - The
is-clickedclass is written in Liquid on the cell whose value equalsoption.selected_value, and Flickity'sinitialIndexis that selector, so the carousel opens on the current variant rather than the first cell. variantRadiosMapis 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 threesourcebreakpoints — 360px below 768px, 535px above — withloading="lazy"on theimg, 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_productwhere the other two passproduct, so one snippet serves three product sections without a branch.
Edge Cases
- A product with no option named
colororcolorsrenders no cells;createShadesSliderthen throws "Carousel or variant radios are missing," and the caller'scatchlogs it and moves on. - A variant with no
custom.face_imagefalls back tovariant.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-clickedcell is chosen fromoption.selected_value, so a URL that lands on a specific variant opens the carousel on that shade. staticClickfires only for a tap that Flickity didn't interpret as a drag, so swiping through faces doesn't change the variant.
Platform Primitives Used
- Metafields —
custom.face_imageon the variant is the carousel's content, andcustom.display_shades_carouselon the product is the switch that mounts it. - Liquid section schema and settings — the snippet renders inside the product section, whose
section.idis 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
- Metafield-driven PDP content blocks — the primary capability: a product-page module whose content is per-variant photography in a metafield and whose presence is a product metafield.
- Variant swatches and shade pickers — a second visual treatment of the shade option, layered over the native picker rather than replacing it.
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
- Color-accurate shade swatches driven by a merchandiser-editable hex registry — Nudestix. The swatch row this carousel sits beside, and the page that documents the gate that switches the carousel on.
- Grouped shade swatch picker with shade-finder dialog and GA4 shade-finder events — Nudestix. The same option, grouped by a finish metafield on the single-product template.
- Shade swatch rendering and per-variant content payload — Nudestix. The bundle and quick-view rendering of the picker, where the subject is the per-variant content published beside the swatches.
- Pill variant picker with merchandiser-named size labels — LUS Brands. A different storefront putting merchandiser-authored presentation over Dawn's native option controls.
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-radioselement 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.