← SVG & icons

Turbulence Warp

An SVG turbulence filter makes any element waver like heat haze over a hot road. Fractal noise nudges every pixel about, and the noise slowly stretches and shrinks so the shimmer keeps flowing. It works on text, images and whole sections.

Heat haze

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-turbulence-warp to any element: text, an image or a section.
  3. For a clean edge, put it on an inner layer a little bigger than a parent with overflow: hidden, as the warp moves the edges too.

Options

Add any of these attributes to the same element.

data-strengthHow far pixels are pushed. Default 14 px.
data-frequencySize of the ripples across and up; bigger is finer. Default 0.012 0.045.
data-speedHow fast the shimmer flows. Default 1.
data-hoverCalm until pointed at or tapped, then it shimmers.

Filters cost more to draw than most effects, so use it on one or two elements at a time. It pauses while off screen, and with reduced motion it isn’t applied.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Turbulence Warp: an SVG turbulence filter makes any element waver like
    // heat haze over a hot road. Fractal noise pushes the pixels about, and
    // the noise slowly stretches and shrinks so the shimmer keeps flowing.
    // Usage: <div data-turbulence-warp>…any content…</div>
    // Optional: data-strength (how far pixels move, in px, default 14),
    // data-frequency (noise scale across and up, default "0.012 0.045"; bigger
    // is finer), data-speed (default 1), data-hover (calm until pointed at or
    // touched, then it shimmers).
    // The warp also moves the element's edges, so for a clean edge put it on
    // an inner layer slightly bigger than a parent with overflow: hidden.
    var DEFAULTS = { strength: 14, frequency: '0.012 0.045', speed: 1 };
    var NS = 'http://www.w3.org/2000/svg';
    var uid = 0, host = null;

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

    function make(tag, attrs, parent) {
      var n = document.createElementNS(NS, tag);
      for (var k in attrs) n.setAttribute(k, attrs[k]);
      if (parent) parent.appendChild(n);
      return n;
    }

    function setup(el) {
      var d = el.dataset;
      var id = 'tbw-' + ++uid;
      if (!host) {
        // One hidden SVG holds every warp filter on the page.
        host = make('svg', { width: 0, height: 0, 'aria-hidden': 'true', focusable: 'false' });
        host.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden';
        document.body.appendChild(host);
      }
      var f = make('filter', { id: id, x: '-5%', y: '-5%', width: '110%', height: '110%', 'color-interpolation-filters': 'sRGB' }, host);
      var freq = (d.frequency || DEFAULTS.frequency).split(/[\s,]+/).map(Number);
      if (freq.length < 2 || isNaN(freq[1])) freq[1] = freq[0];
      var turb = make('feTurbulence', { type: 'fractalNoise', baseFrequency: freq[0] + ' ' + freq[1], numOctaves: 2, seed: uid * 7, result: 'n' }, f);
      var disp = make('feDisplacementMap', { in: 'SourceGraphic', in2: 'n', scale: 0, xChannelSelector: 'R', yChannelSelector: 'G' }, f);
      el.style.filter = 'url(#' + id + ')';
      return { el: el, turb: turb, disp: disp, fx: freq[0], fy: freq[1],
        strength: d.strength !== undefined ? +d.strength : DEFAULTS.strength, speed: +d.speed || DEFAULTS.speed,
        hover: d.hover !== undefined && d.hover !== 'false', level: 0, target: 1 };
    }

    function draw(o, t) {
      // The noise slowly stretches and shrinks, so the ripples flow and swirl:
      // enough to shimmer, never to jump.
      var fx = o.fx * (1 + 0.18 * Math.sin(t * 0.7)), fy = o.fy * (1 + 0.14 * Math.sin(t * 1.1 + 1));
      o.turb.setAttribute('baseFrequency', fx.toFixed(5) + ' ' + fy.toFixed(5));
      o.disp.setAttribute('scale', (o.strength * o.level * (0.85 + 0.15 * Math.sin(t * 2.1))).toFixed(2));
    }

    function start(o) {
      var visible = false, raf = 0, last = 0, t = Math.random() * 10;
      var frame = function (now) {
        raf = 0;
        if (!visible) return;
        var dt = Math.min(0.05, (now - (last || now)) / 1000);
        last = now;
        t += dt * o.speed;
        o.level += (o.target - o.level) * Math.min(1, dt * 4);
        draw(o, t);
        raf = requestAnimationFrame(frame);
      };
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        last = 0;
        if (visible && !raf) raf = requestAnimationFrame(frame);
      }).observe(o.el);
      if (o.hover) {
        o.target = 0;
        var calm;
        o.el.addEventListener('pointerenter', function () { clearTimeout(calm); o.target = 1; });
        o.el.addEventListener('pointerleave', function () { o.target = 0; });
        o.el.addEventListener('pointerdown', function (e) {
          if (e.pointerType === 'mouse') return;
          o.target = 1;
          clearTimeout(calm);
          calm = setTimeout(function () { o.target = 0; }, 2500);
        });
      }
    }

    function init() {
      document.querySelectorAll('[data-turbulence-warp]').forEach(function (el) {
        if (el._tbw) return;
        el._tbw = true;
        if (reduced()) return;
        var o = (el._tbw = setup(el));
        start(o);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h2 data-turbulence-warp>Heat haze</h2>

<!-- clean edges: -->
<div style="overflow: hidden">
  <img data-turbulence-warp src="/road.jpg" alt="A desert road" style="margin: -12px">
</div>