← E-commerce

Swatch Colour Swap

Pick a colour swatch and the product takes on that colour in a ripple: a circle of the new colour spreads across it from the side nearest the swatch, with a faint ring running ahead. The chosen swatch gets a ring that pops in and the colour’s name updates.

Colour: Violet

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-swatch-colour-swap to the box holding the product and its swatches.
  3. Mark the product with data-swatch-target. Draw it in currentColor (an SVG with fill="currentColor"), since the colour is set through CSS color.
  4. Give each swatch a data-color and a data-name. An element with data-swatch-label shows the chosen name.

Options

Add any of these attributes to the same element.

data-durationHow long the ripple takes. Default 700 ms.
data-selectedOn a swatch: start with that one chosen. Default the first.
data-autoplayIt works through the swatches by itself while in view, until someone picks one. Handy for demos.

Each pick fires a swatch event with e.detail.color and e.detail.name, so you can switch the real product photo too.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Swatch Colour Swap: pick a colour swatch and the product takes on that
    // colour in a ripple, a circle of the new colour spreading across it from
    // the side nearest the swatch. The chosen swatch gets a ring that pops in,
    // and the colour's name updates.
    // Usage:
    //   <div data-swatch-colour-swap>
    //     <div data-swatch-target>…an SVG drawn in currentColor…</div>
    //     <span data-swatch-label>Violet</span>
    //     <button data-color="#6a0dad" data-name="Violet" aria-label="Violet"></button>
    //     <button data-color="#0ea5e9" data-name="Sky" aria-label="Sky"></button>
    //   </div>
    // The target is recoloured through CSS color, so draw the product with
    // currentColor (SVG fill="currentColor"). Swatches are any elements with a
    // data-color; the script paints them that colour. A [data-swatch-label]
    // element, if there is one, shows the chosen swatch's data-name.
    // Optional: data-duration (ms the ripple takes, default 700),
    // data-autoplay (it works through the swatches by itself while in view,
    // until someone picks one; handy for demos).
    // Listen for the "swatch" event: e.detail.color and e.detail.name.
    var DEFAULTS = { duration: 700, every: 2000 };

    var css =
      '.scs-target{position:relative;overflow:hidden}' +
      '.scs-ink{position:absolute;inset:0;pointer-events:none}' +
      '.scs-ring{position:absolute;width:0;height:0;border:2px solid;border-radius:50%;translate:-50% -50%;pointer-events:none}' +
      '.scs-swatch{position:relative;cursor:pointer;-webkit-tap-highlight-color:transparent}' +
      '.scs-swatch::after{content:"";position:absolute;inset:-5px;border-radius:inherit;border:2px solid var(--scs-ring,currentColor);' +
      'opacity:0;transform:scale(.7);transition:opacity .25s,transform .35s cubic-bezier(.3,1.6,.5,1)}' +
      '.scs-swatch.scs-on::after{opacity:1;transform:scale(1)}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function setup(el) {
      var target = el.querySelector('[data-swatch-target]');
      var swatches = Array.prototype.slice.call(el.querySelectorAll('[data-color]'));
      if (!target || !swatches.length) return;
      target.classList.add('scs-target');
      var label = el.querySelector('[data-swatch-label]');
      swatches.forEach(function (sw) {
        sw.classList.add('scs-swatch');
        sw.style.background = sw.dataset.color;
        sw.style.setProperty('--scs-ring', sw.dataset.color);
        if (sw.tagName === 'BUTTON') {
          if (!sw.getAttribute('type')) sw.type = 'button';
          if (!sw.getAttribute('aria-label') && sw.dataset.name) sw.setAttribute('aria-label', sw.dataset.name);
          sw.setAttribute('aria-pressed', 'false');
        }
      });
      var s = el._scs = { target: target, swatches: swatches, label: label, current: null, busy: null };
      var first = swatches.filter(function (sw) { return sw.hasAttribute('data-selected'); })[0] || swatches[0];
      pick(el, first, true);
      el.addEventListener('click', function (e) {
        var sw = e.target.closest('.scs-swatch');
        if (!sw || !el.contains(sw)) return;
        if (e.isTrusted) s.user = true;
        pick(el, sw);
      });
    }

    function pick(el, sw, instant) {
      var s = el._scs;
      if (s.current === sw) return;
      s.current = sw;
      var color = sw.dataset.color, name = sw.dataset.name || '';
      s.swatches.forEach(function (o) {
        o.classList.toggle('scs-on', o === sw);
        if (o.hasAttribute('aria-pressed')) o.setAttribute('aria-pressed', o === sw);
      });
      if (s.label && name) s.label.textContent = name;
      if (!instant) el.dispatchEvent(new CustomEvent('swatch', { bubbles: true, detail: { color: color, name: name } }));
      if (instant || reduced()) { finishInk(s); s.target.style.color = color; return; }

      anime.animate(sw, { scale: [0.8, 1], duration: 500, ease: 'outElastic(1, .5)' });
      if (s.label) anime.animate(s.label, { opacity: [0, 1], y: [6, 0], duration: 350, ease: 'out(2)' });

      // A copy of the product in the new colour, revealed by a growing circle
      // from the point on the product nearest the swatch.
      finishInk(s);
      var t = s.target.getBoundingClientRect(), r = sw.getBoundingClientRect();
      var ox = Math.max(0, Math.min(t.width, r.left + r.width / 2 - t.left));
      var oy = Math.max(0, Math.min(t.height, r.top + r.height / 2 - t.top));
      var far = Math.max(Math.hypot(ox, oy), Math.hypot(t.width - ox, oy), Math.hypot(ox, t.height - oy), Math.hypot(t.width - ox, t.height - oy));
      var ink = document.createElement('div');
      ink.className = 'scs-ink';
      ink.setAttribute('aria-hidden', 'true');
      Array.prototype.forEach.call(s.target.childNodes, function (n) { ink.appendChild(n.cloneNode(true)); });
      var cs = getComputedStyle(s.target);
      ink.style.display = cs.display;
      ink.style.placeItems = cs.placeItems;
      ink.style.padding = cs.padding;
      ink.style.color = color;
      s.target.appendChild(ink);
      var p = { r: 0 };
      s.ink = ink;
      s.inkColor = color;
      s.inkAnim = anime.animate(p, {
        r: far,
        duration: +el.dataset.duration || DEFAULTS.duration,
        ease: 'inOut(2)',
        onUpdate: function () { ink.style.clipPath = 'circle(' + p.r + 'px at ' + ox + 'px ' + oy + 'px)'; },
        onComplete: function () { finishInk(s); }
      });
      // A faint ring runs ahead of the colour across the backdrop.
      var ring = document.createElement('span');
      ring.className = 'scs-ring';
      ring.setAttribute('aria-hidden', 'true');
      ring.style.cssText = 'left:' + ox + 'px;top:' + oy + 'px;border-color:' + color;
      s.target.appendChild(ring);
      anime.animate(ring, {
        width: [0, far * 2], height: [0, far * 2], opacity: [0.6, 0], duration: (+el.dataset.duration || DEFAULTS.duration) * 1.2, ease: 'out(2)',
        onComplete: function () { ring.remove(); }
      });
    }

    function finishInk(s) {
      if (!s.ink) return;
      if (s.inkAnim) s.inkAnim.pause();
      s.target.style.color = s.inkColor;
      s.ink.remove();
      s.ink = null;
    }

    function autoplay(el) {
      var s = el._scs, timer = null;
      new IntersectionObserver(function (entries) {
        clearInterval(timer);
        if (!entries[0].isIntersecting) return;
        timer = setInterval(function () {
          if (s.user) { clearInterval(timer); return; }
          var i = s.swatches.indexOf(s.current);
          pick(el, s.swatches[(i + 1) % s.swatches.length]);
        }, DEFAULTS.every);
      }, { threshold: 0.3 }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-swatch-colour-swap]').forEach(function (el) {
        if (el._scsDone) return;
        el._scsDone = true;
        setup(el);
        if (el._scs && el.dataset.autoplay !== undefined && el.dataset.autoplay !== 'false' && !reduced()) autoplay(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-swatch-colour-swap>
  <div data-swatch-target>
    <svg viewBox="0 0 160 100"><path fill="currentColor" d="…"/></svg>
  </div>
  <p>Colour: <b data-swatch-label>Violet</b></p>
  <button data-color="#6a0dad" data-name="Violet" aria-label="Violet"></button>
  <button data-color="#f43f5e" data-name="Coral" aria-label="Coral"></button>
  <button data-color="#0d9488" data-name="Teal" aria-label="Teal"></button>
</div>