← Filters & retro

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

  1. Paste the snippet just before </head>.
  2. Put two images in an element with data-double-exposure and give it a size. The first is the base; the second is blended over it.
  3. 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-blendHow the two mix: screen (default), lighten, multiply or overlay.
data-durationTime for one swell. Default 5000 ms.
data-minThe weakest the second image gets, from 0 to 1. Default 0.35.
data-tonemono for black and white or warm for a faded print.

With reduced motion the two images sit blended, half and half, and stay still.

1 · Snippet
<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>
2 · Your HTML
<figure data-double-exposure style="height:400px">
  <img src="portrait.jpg" alt="A woman in profile">
  <img src="forest.jpg" alt="">
</figure>