← Filters & retro

Tilt-shift Miniature

Blur the top and bottom of a photo, leave a sharp band across the middle and punch up the colours, and a real place starts to look like a tiny model village. The blur closes in when it comes into view, then the sharp band drifts slowly up and down like a camera hunting for focus.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap an image in an element with data-tilt-shift-miniature and give the wrapper a size. The image fills it, cropped to cover.
  3. It works with any image, even from another site. Photos taken from high up, looking down at a slant, look the most like models.

Options

Add any of these attributes to the same element.

data-focusWhere the sharp band sits, as a percentage from the top. Default 55.
data-widthHalf the height of the sharp band, as a percentage. Default 10.
data-blurThe blur at the far top and bottom. Default 8 px.
data-driftHow far the band wanders up and down, as a percentage. Default 8; 0 keeps it still.
data-durationTime for one full wander. Default 9000 ms.
data-saturateHow much the colours are boosted. Default 1.45.

With reduced motion the effect is there from the start and stays still.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Tilt-shift Miniature: blurs the top and bottom of a photo, leaving a
    // sharp band across the middle, and punches up the colour, so a real scene
    // looks like a tiny model. The blur closes in when it comes into view and
    // the sharp band drifts slowly up and down, like a camera hunting for focus.
    // Usage: <figure data-tilt-shift-miniature><img src="photo.jpg" alt="…"></figure>
    // The image (any <img>, or an inline <svg>) fills the box, cropped to cover.
    // Works best on photos taken from high up, looking down at a slant.
    // Optional: data-focus (where the sharp band sits, % from the top, default
    // 55), data-width (half the height of the sharp band, %, default 10),
    // data-blur (blur at the far edges, px, default 8), data-drift (how far the
    // band wanders, %, default 8; 0 keeps it still), data-duration (one full
    // wander, ms, default 9000), data-saturate (colour boost, default 1.45).
    var DEFAULTS = { focus: 55, width: 10, blur: 8, drift: 8, duration: 9000, saturate: 1.45 };

    // Each blur layer is masked away inside the sharp band and a little beyond, a little
    // further for each stronger layer, so the blur builds up smoothly away from the band.
    var masks = '';
    for (var k = 1; k <= 3; k++) {
      var inner = 'calc(var(--tsm-w) + ' + (k - 1) + ' * var(--tsm-s))';
      var outer = 'calc(var(--tsm-w) + ' + k + ' * var(--tsm-s))';
      var g = 'linear-gradient(#000 0,#000 calc(var(--tsm-f) - ' + outer + '),transparent calc(var(--tsm-f) - ' + inner + '),' +
        'transparent calc(var(--tsm-f) + ' + inner + '),#000 calc(var(--tsm-f) + ' + outer + '),#000 100%)';
      masks += '.tsm-b' + k + '{-webkit-mask-image:' + g + ';mask-image:' + g + '}';
    }

    var css =
      '.tsm{position:relative;overflow:hidden;--tsm-f:55%;--tsm-w:50%;--tsm-s:9%}' +
      '.tsm-src{opacity:0}' +
      '.tsm-layer{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none;max-width:none}' +
      '.tsm-vig{position:absolute;inset:0;pointer-events:none;background:radial-gradient(120% 90% at 50% var(--tsm-f),transparent 55%,rgba(0,0,0,.28))}' +
      masks;
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    // An image URL for the layers: the <img> itself, or the inline <svg> as a data URL.
    function sourceUrl(node) {
      if (node.tagName.toLowerCase() !== 'svg') return node.currentSrc || node.src;
      var vb = node.viewBox && node.viewBox.baseVal;
      var clone = node.cloneNode(true);
      clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
      if (vb && vb.width) { clone.setAttribute('width', vb.width); clone.setAttribute('height', vb.height); }
      return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(new XMLSerializer().serializeToString(clone));
    }

    function num(v, def) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : def; }

    function setup(el, src) {
      var d = el.dataset;
      var focus = num(d.focus, DEFAULTS.focus), width = num(d.width, DEFAULTS.width);
      var blur = num(d.blur, DEFAULTS.blur), drift = num(d.drift, DEFAULTS.drift);
      var duration = num(d.duration, DEFAULTS.duration), sat = num(d.saturate, DEFAULTS.saturate);
      var url = sourceUrl(src);
      var fit = src.tagName.toLowerCase() === 'svg' ? '' : getComputedStyle(src).objectPosition;

      // A sharp, colour-boosted copy, then three blurrier copies on top. Blurred copies are
      // scaled up a touch so their soft edges never show.
      for (var k = 0; k <= 3; k++) {
        var im = document.createElement('img');
        im.src = url;
        im.alt = '';
        im.setAttribute('aria-hidden', 'true');
        im.className = 'tsm-layer' + (k ? ' tsm-b' + k : '');
        if (fit) im.style.objectPosition = fit;
        var b = blur * [0, 0.3, 0.6, 1][k];
        im.style.filter = 'saturate(' + sat + ') contrast(1.08)' + (k ? ' blur(' + b.toFixed(1) + 'px)' : '');
        if (k) im.style.transform = 'scale(' + (1 + b / 120).toFixed(3) + ')';
        el.appendChild(im);
      }
      var vig = document.createElement('span');
      vig.className = 'tsm-vig';
      vig.setAttribute('aria-hidden', 'true');
      el.appendChild(vig);
      src.classList.add('tsm-src');

      var state = { f: focus, w: width, t: 0 };
      function apply() {
        el.style.setProperty('--tsm-f', state.f.toFixed(2) + '%');
        el.style.setProperty('--tsm-w', state.w.toFixed(2) + '%');
      }

      if (reduced()) { apply(); return; }

      // Start fully sharp; the blur closes in from the edges once it is seen.
      state.w = 60;
      apply();
      var last = 0;
      var timer = anime.createTimer({
        loop: true, duration: 1000, autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = last ? Math.min(60, now - last) : 16;
          last = now;
          state.t += dt / duration;
          state.f = focus + Math.sin(state.t * Math.PI * 2) * drift;
          apply();
        }
      });

      var shown = false;
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) {
          if (!shown) {
            shown = true;
            anime.animate(state, { w: width, duration: 1800, delay: 250, ease: 'inOut(3)', onUpdate: apply });
          }
          last = 0;
          if (drift) timer.play();
        } else timer.pause();
      }, { threshold: 0.2 }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-tilt-shift-miniature]').forEach(function (el) {
        if (el._tsm) return;
        var src = el.querySelector('img, svg');
        if (!src) return;
        el._tsm = true;
        el.classList.add('tsm');
        setup(el, src);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<figure data-tilt-shift-miniature style="height:400px">
  <img src="village.jpg" alt="A village seen from a hilltop">
</figure>