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
- Paste the snippet just before
</head>. - Add
data-swatch-colour-swapto the box holding the product and its swatches. - Mark the product with
data-swatch-target. Draw it incurrentColor(an SVG withfill="currentColor"), since the colour is set through CSScolor. - Give each swatch a
data-colorand adata-name. An element withdata-swatch-labelshows the chosen name.
Options
Add any of these attributes to the same element.
data-duration | How long the ripple takes. Default 700 ms. |
data-selected | On a swatch: start with that one chosen. Default the first. |
data-autoplay | It 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.
<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><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>