Double Exposure
Blend two pictures like a photo exposed twice. The second image is screened over the first, so it shows through the dark parts and fades into the light ones: a silhouette filled with a landscape is the classic. The balance slowly swells and ebbs while each layer drifts, so the two keep melting into each other.
How to use it
- Paste the snippet just before
</head>. - Put two images in an element with
data-double-exposureand give it a size. The first is the base; the second is blended over it. - A dark subject on a light background as the first image, and a landscape as the second, gives the classic look.
Options
Add any of these attributes to the same element.
data-blend | How the two mix: screen (default), lighten, multiply or overlay. |
data-duration | Time for one swell. Default 5000 ms. |
data-min | The weakest the second image gets, from 0 to 1. Default 0.35. |
data-tone | mono for black and white or warm for a faded print. |
With reduced motion the two images sit blended, half and half, and stay still.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Double Exposure: layers two images like a photo exposed twice. The
// second image is screened over the first, so it shows through the dark
// parts (a silhouette works beautifully) and fades out over the light
// ones. The balance between them slowly swells and ebbs while each layer
// drifts gently, so the two keep melting into each other.
// Usage: <figure data-double-exposure><img src="portrait.jpg" alt="…"><img src="forest.jpg" alt=""></figure>
// The first image is the base, the second is blended over it (an <img>,
// <video> or inline <svg> each). Give the wrapper a size.
// Optional: data-blend (how they mix: "screen", the default, "lighten",
// "multiply" or "overlay"), data-duration (ms for one swell, default
// 5000), data-min (the weakest the second image gets, 0 to 1, default
// 0.35), data-tone ("mono" for black and white, "warm" for a faded warm
// print, or none).
var DEFAULTS = { blend: 'screen', duration: 5000, min: 0.35 };
var TONES = { mono: 'grayscale(1) contrast(1.1)', warm: 'sepia(.35) saturate(1.1) contrast(1.05)' };
var css =
'.dex{position:relative;overflow:hidden;isolation:isolate}' +
'.dex-layer{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}' +
'.dex-top{will-change:transform,opacity}';
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 d = el.dataset;
var layers = el.querySelectorAll(':scope > img, :scope > svg, :scope > video, :scope > picture');
if (layers.length < 2) return;
var base = layers[0], top = layers[1];
var duration = +d.duration || DEFAULTS.duration;
var min = d.min !== undefined ? +d.min : DEFAULTS.min;
base.classList.add('dex-layer');
top.classList.add('dex-layer', 'dex-top');
top.style.mixBlendMode = d.blend || DEFAULTS.blend;
if (TONES[d.tone]) el.style.filter = TONES[d.tone];
// The second picture is decoration over the first.
if (top.tagName.toLowerCase() === 'svg') top.setAttribute('aria-hidden', 'true');
if (reduced()) {
top.style.opacity = (min + 1) / 2;
return;
}
anime.utils.set(top, { opacity: min, scale: 1.12 });
var anims = [
// The swell: the second exposure grows stronger and drifts, then ebbs.
anime.animate(top, {
opacity: [min, 1],
scale: [1.12, 1.02],
x: ['-2%', '2%'],
duration: duration,
ease: 'inOut(2)',
loop: true,
alternate: true,
autoplay: false
}),
// The base breathes the other way, so they slide past each other.
anime.animate(base, {
scale: [1, 1.06],
x: ['1%', '-1%'],
duration: duration * 1.3,
ease: 'inOut(2)',
loop: true,
alternate: true,
autoplay: false
})
];
new IntersectionObserver(function (entries) {
var on = entries[0].isIntersecting;
anims.forEach(function (a) { if (on) a.play(); else a.pause(); });
}, { threshold: 0.1 }).observe(el);
}
function init() {
document.querySelectorAll('[data-double-exposure]').forEach(function (el) {
if (el._dex) return;
el._dex = true;
el.classList.add('dex');
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><figure data-double-exposure style="height:400px">
<img src="portrait.jpg" alt="A woman in profile">
<img src="forest.jpg" alt="">
</figure>