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.
Tilt your phone
How to use it
- Paste the snippet just before
</head>. - Add
data-device-tilt-parallaxto a box with a size. - Give each layer inside it
data-depth, from 0 (still) to 1 (moves the most). Make the layers a little bigger than the box, sayinset: -8%, so their edges never show.
Options
Add any of these attributes to the same element.
data-shift | How far a depth-1 layer moves at full tilt, in px. Default 26. |
data-tilt | How far the box leans, in degrees. Default 7; 0 for none. |
data-range | Degrees of phone tilt for the full effect. Default 20. |
data-autoplay | Sways 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.
<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><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>