Swatch — <mv-swatch>

Variant picker built on native radios (or checkboxes for filters): double-ring color swatches, size chips, product thumbnails; sold-out options crossed out (selectable or disabled), a “Color: Sand” label with hover preview, and required-group validation with a shake and a message.

CategoryForms
TypeWeb Component (<mv-swatch>)
Statusstable
Keywordsswatch, variant, variante, color, couleur, size, taille, radio-group, sold-out, e-commerce

When to use

Avoid when

Install

node scripts/add.mjs swatch --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["swatch"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, components/swatch/swatch.css, components/swatch/swatch.js.

Usage

Canonical markup — start from it and customize with attributes, data-* and CSS variables:

<form id="swatch-demo" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,20rem),1fr));gap:1.5rem 2.5rem;width:min(100%,46rem);margin-inline:auto" onsubmit="event.preventDefault()">
  <div style="display:grid;gap:1.75rem;align-content:start">
    <mv-swatch>
      <fieldset class="mv-swatch" data-type="color" data-check>
        <legend class="mv-swatch-legend">Color <span class="mv-swatch-value"></span></legend>
        <div class="mv-swatch-options">
          <label class="mv-swatch-option" style="--mv-swatch-color:oklch(0.8 0.05 75)"><input type="radio" name="color" value="sand" checked><span class="mv-swatch-label">Sand</span></label>
          <label class="mv-swatch-option" style="--mv-swatch-color:oklch(0.34 0.05 262)" data-tone="dark"><input type="radio" name="color" value="midnight"><span class="mv-swatch-label">Midnight blue</span></label>
          <label class="mv-swatch-option" style="--mv-swatch-color:oklch(0.7 0.06 150)"><input type="radio" name="color" value="sage"><span class="mv-swatch-label">Sage</span></label>
          <label class="mv-swatch-option" style="--mv-swatch-color:oklch(0.58 0.13 38)" data-tone="dark" data-unavailable><input type="radio" name="color" value="brick"><span class="mv-swatch-label">Brick</span></label>
          <label class="mv-swatch-option" style="--mv-swatch-color:linear-gradient(135deg,oklch(0.93 0.02 90) 50%,oklch(0.34 0.05 262) 50%)"><input type="radio" name="color" value="two-tone"><span class="mv-swatch-label">Ecru and midnight</span></label>
        </div>
      </fieldset>
    </mv-swatch>

    <mv-swatch id="swatch-demo-size">
      <fieldset class="mv-swatch" data-type="size">
        <legend class="mv-swatch-legend">Size <span class="mv-swatch-value" data-placeholder="select one"></span></legend>
        <button type="button" class="mv-swatch-aside">Size guide</button>
        <div class="mv-swatch-options">
          <label class="mv-swatch-option"><input type="radio" name="size" value="xs" disabled><span class="mv-swatch-label">XS</span></label>
          <label class="mv-swatch-option"><input type="radio" name="size" value="s" required><span class="mv-swatch-label">S</span></label>
          <label class="mv-swatch-option"><input type="radio" name="size" value="m"><span class="mv-swatch-label">M</span></label>
          <label class="mv-swatch-option" data-unavailable><input type="radio" name="size" value="l"><span class="mv-swatch-label">L</span></label>
          <label class="mv-swatch-option"><input type="radio" name="size" value="xl"><span class="mv-swatch-label">XL</span></label>
          <label class="mv-swatch-option"><input type="radio" name="size" value="xxl" disabled><span class="mv-swatch-label">XXL</span></label>
        </div>
      </fieldset>
    </mv-swatch>
    <div style="display:flex;gap:.5rem;align-items:center">
      <button type="button" class="mv-button" id="swatch-demo-add">Add to cart</button>
      <output id="swatch-demo-out" aria-live="polite" style="font-size:.8125rem;color:var(--mv-fg-muted)"></output>
    </div>
  </div>

  <div style="display:grid;gap:1.75rem;align-content:start">
    <mv-swatch>
      <fieldset class="mv-swatch" data-type="image">
        <legend class="mv-swatch-legend">Style <span class="mv-swatch-value"></span></legend>
        <div class="mv-swatch-options">
          <label class="mv-swatch-option"><img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 400 500'%3E%3Cdefs%3E%3ClinearGradient id='bg' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0' stop-color='oklch(0.97 0.012 80)'/%3E%3Cstop offset='1' stop-color='oklch(0.91 0.025 75)'/%3E%3C/linearGradient%3E%3CradialGradient id='fl'%3E%3Cstop offset='0' stop-color='black' stop-opacity='.22'/%3E%3Cstop offset='1' stop-color='black' stop-opacity='0'/%3E%3C/radialGradient%3E%3ClinearGradient id='sh' x1='0' x2='1'%3E%3Cstop offset='0' stop-color='white' stop-opacity='.22'/%3E%3Cstop offset='.55' stop-color='white' stop-opacity='0'/%3E%3Cstop offset='1' stop-color='black' stop-opacity='.16'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='400' height='500' fill='url(%23bg)'/%3E%3Cdefs%3E%3Cpattern id='k' width='12' height='10' patternUnits='userSpaceOnUse'%3E%3Cpath d='M0 0l6 8 6-8' fill='none' stroke='black' stroke-opacity='.09' stroke-width='1.6'/%3E%3C/pattern%3E%3C/defs%3E%3Cellipse cx='200' cy='420' rx='150' ry='18' fill='url(%23fl)' opacity='1'/%3E%3Cpath d='M160 118 Q200 142 240 118 L276 128 Q300 140 310 176 L336 318 L300 331 L268 222 L267 388 Q200 397 133 388 L132 222 L100 331 L64 318 L90 176 Q100 140 124 128 Z' fill='oklch(0.8 0.05 75)'/%3E%3Cpath d='M160 118 Q200 142 240 118 L276 128 Q300 140 310 176 L336 318 L300 331 L268 222 L267 388 Q200 397 133 388 L132 222 L100 331 L64 318 L90 176 Q100 140 124 128 Z' fill='url(%23k)'/%3E%3Cpath d='M160 118 Q200 142 240 118 L276 128 Q300 140 310 176 L336 318 L300 331 L268 222 L267 388 Q200 397 133 388 L132 222 L100 331 L64 318 L90 176 Q100 140 124 128 Z' fill='url(%23sh)'/%3E%3Cpath d='M160 118 Q200 152 240 118 Q200 136 160 118Z' fill='black' fill-opacity='.22'/%3E%3Cpath d='M154 116 Q200 160 246 116' fill='none' stroke='black' stroke-opacity='.12' stroke-width='5'/%3E%3Cpath d='M133 370 Q200 379 267 370 L267 388 Q200 397 133 388 Z' fill='black' fill-opacity='.1'/%3E%3Cpath d='M64 318 L100 331 L103 316 L68 303 Z' fill='black' fill-opacity='.1'/%3E%3Cpath d='M336 318 L300 331 L297 316 L332 303 Z' fill='black' fill-opacity='.1'/%3E%3Cpath d='M132 222 L133 380 M268 222 L267 380' stroke='black' stroke-opacity='.08' stroke-width='3'/%3E%3C/svg%3E" alt=""><input type="radio" name="style" value="crewneck" checked><span class="mv-swatch-label">Crewneck</span></label>
          <label class="mv-swatch-option"><img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 400 500'%3E%3Cdefs%3E%3ClinearGradient id='bg' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0' stop-color='oklch(0.95 0.012 250)'/%3E%3Cstop offset='1' stop-color='oklch(0.87 0.025 255)'/%3E%3C/linearGradient%3E%3CradialGradient id='fl'%3E%3Cstop offset='0' stop-color='black' stop-opacity='.22'/%3E%3Cstop offset='1' stop-color='black' stop-opacity='0'/%3E%3C/radialGradient%3E%3ClinearGradient id='sh' x1='0' x2='1'%3E%3Cstop offset='0' stop-color='white' stop-opacity='.22'/%3E%3Cstop offset='.55' stop-color='white' stop-opacity='0'/%3E%3Cstop offset='1' stop-color='black' stop-opacity='.16'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='400' height='500' fill='url(%23bg)'/%3E%3Cdefs%3E%3Cpattern id='k' width='12' height='10' patternUnits='userSpaceOnUse'%3E%3Cpath d='M0 0l6 8 6-8' fill='none' stroke='black' stroke-opacity='.09' stroke-width='1.6'/%3E%3C/pattern%3E%3C/defs%3E%3Cellipse cx='200' cy='420' rx='150' ry='18' fill='url(%23fl)' opacity='1'/%3E%3Cpath d='M160 118 Q200 142 240 118 L276 128 Q300 140 310 176 L336 318 L300 331 L268 222 L267 388 Q200 397 133 388 L132 222 L100 331 L64 318 L90 176 Q100 140 124 128 Z' fill='oklch(0.34 0.05 262)'/%3E%3Cpath d='M160 118 Q200 142 240 118 L276 128 Q300 140 310 176 L336 318 L300 331 L268 222 L267 388 Q200 397 133 388 L132 222 L100 331 L64 318 L90 176 Q100 140 124 128 Z' fill='url(%23k)'/%3E%3Cpath d='M160 118 Q200 142 240 118 L276 128 Q300 140 310 176 L336 318 L300 331 L268 222 L267 388 Q200 397 133 388 L132 222 L100 331 L64 318 L90 176 Q100 140 124 128 Z' fill='url(%23sh)'/%3E%3Cpath d='M160 118 Q200 152 240 118 Q200 136 160 118Z' fill='black' fill-opacity='.22'/%3E%3Cpath d='M154 116 Q200 160 246 116' fill='none' stroke='black' stroke-opacity='.12' stroke-width='5'/%3E%3Cpath d='M133 370 Q200 379 267 370 L267 388 Q200 397 133 388 Z' fill='black' fill-opacity='.1'/%3E%3Cpath d='M64 318 L100 331 L103 316 L68 303 Z' fill='black' fill-opacity='.1'/%3E%3Cpath d='M336 318 L300 331 L297 316 L332 303 Z' fill='black' fill-opacity='.1'/%3E%3Cpath d='M132 222 L133 380 M268 222 L267 380' stroke='black' stroke-opacity='.08' stroke-width='3'/%3E%3C/svg%3E" alt=""><input type="radio" name="style" value="crewneck-midnight"><span class="mv-swatch-label">Crewneck, midnight blue</span></label>
          <label class="mv-swatch-option"><img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 400 500'%3E%3Cdefs%3E%3ClinearGradient id='bg' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0' stop-color='oklch(0.96 0.015 145)'/%3E%3Cstop offset='1' stop-color='oklch(0.89 0.03 150)'/%3E%3C/linearGradient%3E%3CradialGradient id='fl'%3E%3Cstop offset='0' stop-color='black' stop-opacity='.22'/%3E%3Cstop offset='1' stop-color='black' stop-opacity='0'/%3E%3C/radialGradient%3E%3ClinearGradient id='sh' x1='0' x2='1'%3E%3Cstop offset='0' stop-color='white' stop-opacity='.22'/%3E%3Cstop offset='.55' stop-color='white' stop-opacity='0'/%3E%3Cstop offset='1' stop-color='black' stop-opacity='.16'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='400' height='500' fill='url(%23bg)'/%3E%3Cdefs%3E%3Cpattern id='k' width='12' height='10' patternUnits='userSpaceOnUse'%3E%3Cpath d='M0 0l6 8 6-8' fill='none' stroke='black' stroke-opacity='.09' stroke-width='1.6'/%3E%3C/pattern%3E%3C/defs%3E%3Cellipse cx='200' cy='392' rx='150' ry='16' fill='url(%23fl)' opacity='1'/%3E%3Crect x='96' y='292' width='208' height='96' rx='16' fill='oklch(0.7 0.06 150)' style='filter:brightness(.9)'/%3E%3Crect x='96' y='292' width='208' height='96' rx='16' fill='url(%23k)'/%3E%3Crect x='96' y='292' width='208' height='96' rx='16' fill='url(%23sh)'/%3E%3Cpath d='M104 356h192' stroke='black' stroke-opacity='.12' stroke-width='3'/%3E%3Crect x='110' y='190' width='180' height='112' rx='16' fill='oklch(0.7 0.06 150)'/%3E%3Crect x='110' y='190' width='180' height='112' rx='16' fill='url(%23k)'/%3E%3Crect x='110' y='190' width='180' height='112' rx='16' fill='url(%23sh)'/%3E%3Cpath d='M160 190 Q200 232 240 190' fill='black' fill-opacity='.14'/%3E%3Cpath d='M166 190 Q200 222 234 190' fill='none' stroke='black' stroke-opacity='.18' stroke-width='5'/%3E%3Cpath d='M118 268h164' stroke='black' stroke-opacity='.1' stroke-width='3'/%3E%3C/svg%3E" alt=""><input type="radio" name="style" value="two-pack"><span class="mv-swatch-label">Two-pack gift box</span></label>
          <label class="mv-swatch-option" data-unavailable><img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 400 500'%3E%3Cdefs%3E%3ClinearGradient id='bg' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0' stop-color='oklch(0.96 0.015 45)'/%3E%3Cstop offset='1' stop-color='oklch(0.89 0.035 40)'/%3E%3C/linearGradient%3E%3CradialGradient id='fl'%3E%3Cstop offset='0' stop-color='black' stop-opacity='.22'/%3E%3Cstop offset='1' stop-color='black' stop-opacity='0'/%3E%3C/radialGradient%3E%3ClinearGradient id='sh' x1='0' x2='1'%3E%3Cstop offset='0' stop-color='white' stop-opacity='.22'/%3E%3Cstop offset='.55' stop-color='white' stop-opacity='0'/%3E%3Cstop offset='1' stop-color='black' stop-opacity='.16'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='400' height='500' fill='url(%23bg)'/%3E%3Cdefs%3E%3Cpattern id='k' width='12' height='10' patternUnits='userSpaceOnUse'%3E%3Cpath d='M0 0l6 8 6-8' fill='none' stroke='black' stroke-opacity='.09' stroke-width='1.6'/%3E%3C/pattern%3E%3C/defs%3E%3Cellipse cx='200' cy='420' rx='150' ry='18' fill='url(%23fl)' opacity='1'/%3E%3Cpath d='M160 118 Q200 142 240 118 L276 128 Q300 140 310 176 L336 318 L300 331 L268 222 L267 388 Q200 397 133 388 L132 222 L100 331 L64 318 L90 176 Q100 140 124 128 Z' fill='oklch(0.58 0.13 38)'/%3E%3Cpath d='M160 118 Q200 142 240 118 L276 128 Q300 140 310 176 L336 318 L300 331 L268 222 L267 388 Q200 397 133 388 L132 222 L100 331 L64 318 L90 176 Q100 140 124 128 Z' fill='url(%23k)'/%3E%3Cpath d='M160 118 Q200 142 240 118 L276 128 Q300 140 310 176 L336 318 L300 331 L268 222 L267 388 Q200 397 133 388 L132 222 L100 331 L64 318 L90 176 Q100 140 124 128 Z' fill='url(%23sh)'/%3E%3Cpath d='M160 118 Q200 152 240 118 Q200 136 160 118Z' fill='black' fill-opacity='.22'/%3E%3Cpath d='M154 116 Q200 160 246 116' fill='none' stroke='black' stroke-opacity='.12' stroke-width='5'/%3E%3Cpath d='M133 370 Q200 379 267 370 L267 388 Q200 397 133 388 Z' fill='black' fill-opacity='.1'/%3E%3Cpath d='M64 318 L100 331 L103 316 L68 303 Z' fill='black' fill-opacity='.1'/%3E%3Cpath d='M336 318 L300 331 L297 316 L332 303 Z' fill='black' fill-opacity='.1'/%3E%3Cpath d='M132 222 L133 380 M268 222 L267 380' stroke='black' stroke-opacity='.08' stroke-width='3'/%3E%3C/svg%3E" alt=""><input type="radio" name="style" value="brick"><span class="mv-swatch-label">Crewneck, brick</span></label>
        </div>
      </fieldset>
    </mv-swatch>

    <mv-swatch>
      <fieldset class="mv-swatch" data-type="size" data-size="sm">
        <legend class="mv-swatch-legend">Shoe size <span class="mv-swatch-value"></span></legend>
        <div class="mv-swatch-options">
          <label class="mv-swatch-option"><input type="radio" name="shoe-size" value="7"><span class="mv-swatch-label">7</span></label>
          <label class="mv-swatch-option"><input type="radio" name="shoe-size" value="8"><span class="mv-swatch-label">8</span></label>
          <label class="mv-swatch-option"><input type="radio" name="shoe-size" value="9" checked><span class="mv-swatch-label">9</span></label>
          <label class="mv-swatch-option" data-unavailable><input type="radio" name="shoe-size" value="10"><span class="mv-swatch-label">10</span></label>
          <label class="mv-swatch-option"><input type="radio" name="shoe-size" value="11"><span class="mv-swatch-label">11</span></label>
          <label class="mv-swatch-option"><input type="radio" name="shoe-size" value="12" disabled><span class="mv-swatch-label">12</span></label>
        </div>
      </fieldset>
    </mv-swatch>

    <mv-swatch>
      <fieldset class="mv-swatch" data-type="color" data-size="sm">
        <legend class="mv-swatch-legend">Filter by color <span class="mv-swatch-value"></span></legend>
        <div class="mv-swatch-options">
          <label class="mv-swatch-option" style="--mv-swatch-color:oklch(0.25 0.01 270)"><input type="checkbox" name="colors" value="black" checked><span class="mv-swatch-label">Black</span></label>
          <label class="mv-swatch-option" style="--mv-swatch-color:oklch(0.93 0.02 90)"><input type="checkbox" name="colors" value="ecru"><span class="mv-swatch-label">Ecru</span></label>
          <label class="mv-swatch-option" style="--mv-swatch-color:oklch(0.74 0.13 75)"><input type="checkbox" name="colors" value="ochre" checked><span class="mv-swatch-label">Ochre</span></label>
          <label class="mv-swatch-option" style="--mv-swatch-color:oklch(0.78 0.07 235)"><input type="checkbox" name="colors" value="sky"><span class="mv-swatch-label">Sky</span></label>
          <label class="mv-swatch-option" style="--mv-swatch-color:oklch(0.8 0.07 15)"><input type="checkbox" name="colors" value="pink"><span class="mv-swatch-label">Dusty pink</span></label>
          <label class="mv-swatch-option" style="--mv-swatch-color:oklch(0.45 0.07 160)"><input type="checkbox" name="colors" value="forest"><span class="mv-swatch-label">Forest green</span></label>
        </div>
      </fieldset>
    </mv-swatch>
  </div>
</form>
<script type="module">
  const form = document.getElementById("swatch-demo");
  const size = form.querySelector("#swatch-demo-size");
  const out = form.querySelector("#swatch-demo-out");
  form.querySelector("#swatch-demo-add").addEventListener("click", async () => {
    await customElements.whenDefined("mv-swatch");
    if (!size.validate("Select a size to continue.")) { out.textContent = ""; return; }
    const d = new FormData(form);
    out.textContent = `Added: ${d.get("color")} · ${d.get("size").toUpperCase()}`;
  });
  form.addEventListener("mv-change", (e) => {
    if (e.detail.name === "size" && !e.detail.available) out.textContent = "Sold out in this size. We’ll let you know when it’s back.";
    else if (e.detail.name === "size") out.textContent = "";
  });
</script>

API

Attributes

NameTypeDefaultDescription
data-typecolor | size | imagecolorOn .mv-swatch: round swatches, text chips or 4:5 thumbnails.
data-sizesm | lgOn .mv-swatch: option size.
data-layoutgridOn .mv-swatch: regular grid (sizes).
data-checkbooleanOn .mv-swatch: check mark on the selected swatch.
data-unavailablebooleanOn .mv-swatch-option: crossed out but selectable (“notify me”); “(sold out)” is added for screen readers. input disabled = not selectable.
data-tonedarkOn a dark swatch: light check mark.
data-placeholderstringOn .mv-swatch-value: text shown when nothing is selected.
--mv-swatch-color / --mv-swatch-imagecssOn .mv-swatch-option: swatch color, gradient or image.

Properties

NameTypeDescription
valuestring | string[]Checked value (an array for checkboxes).

Methods

NameDescription
validate(message?)Required group: returns false and sets the invalid state, a message (role=alert) and focus when nothing is selected.

Events

NameDescription
mv-changedetail: { name, value, label, available, option }.

Content structure

NameDescription
.mv-swatch-legend > .mv-swatch-valueLabel of the selected option (kept in sync, previews on hover).
.mv-swatch-asideLink or button to the right of the legend (“Size guide”).
.mv-swatch-option > input + .mv-swatch-labelOne option; optional <img> for the image type; .mv-swatch-hint for extra info (“EU”).

CSS classes

NameDescription
mv-swatch / -legend / -value / -aside / -options / -option / -label / -hint / -errorStructure.

CSS variables

NameDefaultDescription
--mv-swatch-size2.25remBase option size.
--mv-swatch-radiusvar(--mv-radius-md)Size chip corner radius.

Accessibility

<fieldset> + <legend> and native radios: Tab enters the group, arrow keys change the option (disabled options are skipped), native required. Color labels are visually hidden but remain the accessible name; selectable sold-out options announce “(sold out)”. Visible focus ring; selection shown by ring/inversion (not color alone). The legend’s dynamic label is decorative (aria-hidden); the validation error is a role=alert.