← Gestures

Device Tilt Parallax

Tilt your phone and the layers of a scene slide against each other, near ones further than far ones, while the card leans a touch as if you were looking into it. On a computer the mouse does the tilting, and with neither it sways gently by itself.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-device-tilt-parallax to a box with a size.
  3. Give each layer inside it data-depth, from 0 (still) to 1 (moves the most). Make the layers a little bigger than the box, say inset: -8%, so their edges never show.

Options

Add any of these attributes to the same element.

data-shiftHow far a depth-1 layer moves at full tilt, in px. Default 26.
data-tiltHow far the box leans, in degrees. Default 7; 0 for none.
data-rangeDegrees of phone tilt for the full effect. Default 20.
data-autoplaySways in a slow figure of eight until the phone or mouse takes over.

However the phone is held when it starts counts as level, and it slowly follows if you change grip. iPhones ask permission for motion, so the script adds a small “Enable tilt” button there.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Device Tilt Parallax: tilt your phone and the layers of a scene slide
    // against each other, near ones further than far ones, while the whole card
    // leans a touch, as if you were looking into it. On a computer the mouse
    // does the tilting; with neither, it can sway gently by itself.
    // Usage:
    //   <div data-device-tilt-parallax>
    //     <div data-depth="0.2">far</div>
    //     <div data-depth="1">near</div>
    //   </div>
    // Each child with data-depth (0 still, 1 moves the most) is a layer; make
    // the layers a little bigger than the box so their edges never show.
    // Optional: data-shift (px a depth-1 layer moves at full tilt, default 26),
    // data-tilt (degrees the card leans, default 7; 0 for none), data-range
    // (degrees of phone tilt for full effect, default 20), data-autoplay (sway
    // by itself until the phone or mouse takes over).
    // On iPhone, motion needs permission: the script adds a small button that asks.
    var DEFAULTS = { shift: 26, tilt: 7, range: 20 };

    var css =
      '.dtp{position:relative;overflow:hidden;transform-style:preserve-3d;will-change:transform}' +
      '.dtp>[data-depth]{will-change:transform}' +
      '.dtp-ask{position:absolute;right:10px;bottom:10px;z-index:5;padding:7px 12px;border:0;border-radius:99px;background:rgba(255,255,255,.88);' +
      'color:#2b1748;font:600 12px/1 system-ui,sans-serif;box-shadow:0 4px 12px rgba(0,0,0,.18);cursor:pointer}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    var fine = window.matchMedia('(hover: hover) and (pointer: fine)');

    function setup(el) {
      var d = el.dataset;
      var shift = d.shift !== undefined ? +d.shift : DEFAULTS.shift;
      var tilt = d.tilt !== undefined ? +d.tilt : DEFAULTS.tilt;
      var range = +d.range || DEFAULTS.range;
      var thumb = !!el.closest('[aria-hidden="true"]');
      el.classList.add('dtp');
      var layers = [].slice.call(el.querySelectorAll(':scope > [data-depth]')).map(function (n) {
        return { n: n, k: +n.getAttribute('data-depth') || 0 };
      });

      var s = el._dtp = { x: 0, y: 0, tx: 0, ty: 0 };
      var source = null, sourceAt = -1e9, base = null, visible = false, raf = 0, t0 = performance.now();

      function draw() {
        layers.forEach(function (l) {
          l.n.style.transform = 'translate3d(' + (s.x * shift * l.k).toFixed(2) + 'px,' + (s.y * shift * l.k).toFixed(2) + 'px,0)';
        });
        if (tilt) el.style.transform = 'perspective(800px) rotateX(' + (-s.y * tilt).toFixed(2) + 'deg) rotateY(' + (s.x * tilt).toFixed(2) + 'deg)';
      }

      function tick(now) {
        raf = 0;
        if (!visible) return;
        var idle = !source || (source === 'tilt' && now - sourceAt > 2500);
        if (idle && d.autoplay !== undefined) {
          // A slow figure of eight.
          var t = (now - t0) / 1000;
          s.tx = Math.sin(t * 0.9) * 0.85;
          s.ty = Math.sin(t * 1.8) * 0.45;
        } else if (idle) {
          s.tx = s.ty = 0;
        }
        s.x += (s.tx - s.x) * 0.08;
        s.y += (s.ty - s.y) * 0.08;
        draw();
        raf = requestAnimationFrame(tick);
      }
      function wake() { if (!raf && visible && !reduced()) raf = requestAnimationFrame(tick); }

      function clamp(v) { return Math.max(-1, Math.min(1, v)); }

      function onTilt(e) {
        if (e.gamma === null || e.beta === null) return;
        if (!visible) return;
        // Treat however the phone is held as level, drifting slowly to follow it.
        if (!base) base = { g: e.gamma, b: e.beta };
        base.g += (e.gamma - base.g) * 0.004;
        base.b += (e.beta - base.b) * 0.004;
        var angle = (screen.orientation && screen.orientation.angle) || window.orientation || 0;
        var gx = e.gamma - base.g, by = e.beta - base.b;
        var x = gx, y = by;
        if (angle === 90) { x = by; y = -gx; } else if (angle === -90 || angle === 270) { x = -by; y = gx; } else if (angle === 180) { x = -gx; y = -by; }
        s.tx = clamp(x / range);
        s.ty = clamp(y / range);
        source = 'tilt';
        sourceAt = performance.now();
        wake();
      }

      function listen() { window.addEventListener('deviceorientation', onTilt); }

      if (!thumb) {
        var D = window.DeviceOrientationEvent;
        if (D) listen();
        // Some browsers (iPhone) only send motion after asking; the button goes
        // as soon as any motion arrives.
        if (D && typeof D.requestPermission === 'function' && window.matchMedia('(pointer: coarse)').matches) {
          var ask = document.createElement('button');
          ask.type = 'button';
          ask.className = 'dtp-ask';
          ask.textContent = 'Enable tilt';
          el.appendChild(ask);
          ask.addEventListener('click', function () {
            D.requestPermission().then(function (r) {
              if (r !== 'granted') ask.textContent = 'Tilt not allowed';
            }).catch(function () { ask.textContent = 'Tilt not available'; });
          });
          window.addEventListener('deviceorientation', function gone(e) {
            if (e.gamma === null) return;
            window.removeEventListener('deviceorientation', gone);
            ask.remove();
          });
        }
        el.addEventListener('pointermove', function (e) {
          if (source === 'tilt' && performance.now() - sourceAt < 1000) return;
          if (e.pointerType !== 'mouse' && !fine.matches) return;
          var r = el.getBoundingClientRect();
          s.tx = clamp(((e.clientX - r.left) / r.width - 0.5) * 2);
          s.ty = clamp(((e.clientY - r.top) / r.height - 0.5) * 2);
          source = 'pointer';
          sourceAt = performance.now();
          wake();
        });
        el.addEventListener('pointerleave', function () {
          if (source !== 'pointer') return;
          s.tx = s.ty = 0;
          source = null;
          t0 = performance.now();   // sway on again from the middle
        });
      }

      draw();
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible) wake();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-device-tilt-parallax]').forEach(function (el) {
        if (el._dtp) return;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-device-tilt-parallax style="position: relative; height: 300px; overflow: hidden">
  <img data-depth="0.2" src="sky.png" alt="" style="position: absolute; inset: -8%">
  <img data-depth="0.6" src="hills.png" alt="" style="position: absolute; inset: -8%">
  <img data-depth="1" src="trees.png" alt="" style="position: absolute; inset: -8%">
</div>