Iridescent Sheen
Oil-slick colours swirl and shift slowly across a section like light on a soap bubble. The bands come from real thin-film physics, so they run through the same magentas, golds, teals and blues a soap film does. Pick vivid oil on black, pale soap or a soft pearl.
Catch the light
How to use it
- Paste the snippet just before
</head>. - Add
data-iridescent-sheento a section.
Options
Add any of these attributes to the same element.
data-mode | oil (vivid bands on black), soap (pale pastels on white) or pearl (a soft lilac sheen). Default oil. |
data-speed | Speed multiplier. Default 1. |
data-scale | Size of the swirls. Default 1. |
It’s drawn small on a canvas behind your content and scaled up smoothly, so it stays light, and it pauses when off screen. With reduced motion it holds still. With soap or pearl, use dark text.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Iridescent Sheen: oil-slick colours swirl and shift across a section like
// light on a soap film. The colours come from real thin-film interference:
// a slowly flowing film thickness is turned into the colours a film that
// thick reflects. Drawn small on a canvas behind your content and scaled
// up, and paused when off screen.
// Usage: <section data-iridescent-sheen>…</section>
// Optional: data-mode ("oil" for vivid bands on black, "soap" for pale
// pastels on white, "pearl" for a soft lilac sheen; default oil),
// data-speed (default 1), data-scale (size of the swirls, default 1).
var DEFAULTS = { mode: 'oil', speed: 1, scale: 1 };
var MAX_PIXELS = 24000;
var MODES = {
// base colour, strength of the sheen, extra saturation, light (soap/pearl) or dark (oil)
oil: { base: [8, 8, 16], amp: 1, sat: 1.1, lift: 0 },
soap: { base: [236, 238, 246], amp: 0.5, sat: 1.4, lift: 1 },
pearl: { base: [196, 181, 253], amp: 0.5, sat: 1.2, lift: 0.4 }
};
var css =
'[data-iridescent-sheen]{position:relative;isolation:isolate;overflow:hidden}' +
'.irs-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1;pointer-events:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
// Newton's interference colours: the colours a soap or oil film shows as
// it gets thicker, in order, from black through straw, orange, magenta,
// violet, blue, teal and gold, round again to pink and green.
var NEWTON = [
[20, 18, 30], [70, 70, 92], [196, 192, 186], [232, 196, 104], [226, 128, 54],
[196, 44, 86], [124, 42, 174], [48, 62, 204], [32, 148, 206], [30, 186, 160],
[196, 206, 86], [236, 152, 72], [214, 62, 126], [132, 62, 196], [42, 142, 196],
[62, 190, 146], [222, 142, 172]
];
// Spread the sequence over 1024 steps once, eased between stops, and
// blend it with the mode's base colour.
function lut(mode) {
var m = MODES[mode] || MODES.oil, out = new Uint8ClampedArray(1024 * 3);
var n = NEWTON.length - 1;
for (var i = 0; i < 1024; i++) {
var p = (i / 1023) * n, j = Math.min(n - 1, Math.floor(p)), f = p - j;
f = f * f * (3 - 2 * f);
var c = [], k;
for (k = 0; k < 3; k++) c.push((NEWTON[j][k] + (NEWTON[j + 1][k] - NEWTON[j][k]) * f) / 255);
var mean = (c[0] + c[1] + c[2]) / 3;
for (k = 0; k < 3; k++) {
var v = mean + (c[k] - mean) * m.sat; // push the colours apart a little
var base = m.base[k] / 255;
v = m.lift ? base + (v - 0.5) * m.amp : base + Math.pow(Math.max(0, v), 1.25) * m.amp;
out[i * 3 + k] = Math.round(Math.max(0, Math.min(1, v)) * 255);
}
}
return out;
}
function build(el) {
var d = el.dataset;
var colors = lut(d.mode || DEFAULTS.mode);
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
var scale = +d.scale || DEFAULTS.scale;
var canvas = document.createElement('canvas');
canvas.className = 'irs-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var w = 0, h = 0, step = 3, img = null, t = anime.utils.random(0, 60);
function resize() {
var cw = Math.max(1, el.clientWidth), ch = Math.max(1, el.clientHeight);
step = Math.max(3, Math.sqrt((cw * ch) / MAX_PIXELS));
w = Math.ceil(cw / step);
h = Math.ceil(ch / step);
canvas.width = w;
canvas.height = h;
img = ctx.createImageData(w, h);
draw();
}
function draw() {
var data = img.data;
var k = step / (150 * scale); // swirl units per canvas pixel
for (var y = 0; y < h; y++) {
var py = y * k;
for (var x = 0; x < w; x++) {
var px = x * k;
// Warp the coordinates with a few slow waves, then measure the
// film's thickness there: swirly bands that drift and fold.
var qx = px + 0.55 * Math.sin(py * 1.7 + t * 0.31) + 0.3 * Math.sin(py * 3.1 - t * 0.23);
var qy = py + 0.55 * Math.sin(px * 1.4 - t * 0.27) + 0.3 * Math.cos(px * 2.6 + t * 0.19);
var v = Math.sin(qx * 1.9 + t * 0.21) + Math.sin(qy * 2.3 - t * 0.17) +
0.6 * Math.sin((qx + qy) * 2.7 + t * 0.29) + 0.25 * py; // thinner at the top, like a draining bubble
var f = ((v + 2.6) * 150 + t * 14) | 0, i = f & 1023;
// Fold the index so the colours run back and forth, never jump.
if ((f >> 10) & 1) i = 1023 - i;
var o = (y * w + x) * 4, c = i * 3;
data[o] = colors[c];
data[o + 1] = colors[c + 1];
data[o + 2] = colors[c + 2];
data[o + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
resize();
if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
var last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
t += ((last ? Math.min(50, now - last) : 16) / 1000) * speed;
last = now;
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-iridescent-sheen]').forEach(function (el) {
if (el._irs) return;
el._irs = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-iridescent-sheen style="color: #fff">
<h1>Catch the light</h1>
</section>
<!-- pale and soapy: -->
<section data-iridescent-sheen data-mode="soap">…</section>