Żyroskop na mobile w JS i CSS — efekt parallax sterowany ruchem telefonu
Jak czytać dane z żyroskopu w przeglądarce mobilnej i przełożyć je na efekty wizualne w CSS, z obsługą uprawnień iOS.
Problem
Chcemy, żeby element na stronie reagował na pochylenie telefonu — typowy efekt parallax sterowany ruchem urządzenia, „warstwowa" karta produktu, animacja w hero section. Brzmi prosto, ale w praktyce trafiamy na trzy ściany: różne API w różnych przeglądarkach, wymóg jawnej zgody użytkownika na iOS oraz konieczność serwowania strony przez HTTPS.
Wpis pokazuje minimalny, działający przepływ: wykrycie wsparcia, poproszenie o uprawnienia, czytanie orientacji, podanie wartości do CSS i wygładzenie skoków.
Co właściwie czytamy
W przeglądarce dostępne są dwa pokrewne zdarzenia:
deviceorientation— bezwzględna orientacja urządzenia (alpha,beta,gamma) w stopniach. To jest to, czego najczęściej szukamy do parallax.devicemotion— przyspieszenia i prędkości kątowe (accelerationIncludingGravity,rotationRate). Lepsze do shake-detection niż do orientacji.
Pola z deviceorientation:
alpha— obrót wokół osi Z (kompas), 0–360°beta— pochylenie przód-tył, -180° do 180°gamma— pochylenie lewo-prawo, -90° do 90°
Do parallaxu wystarczą beta i gamma.
Wykrycie wsparcia i zgoda na iOS
Safari na iOS 13+ wymaga jawnej zgody użytkownika, którą można poprosić tylko w odpowiedzi na gest (klik, tap). Na Androidzie i starszych wersjach iOS zdarzenie po prostu działa.
const supportsOrientation = 'DeviceOrientationEvent' in window;
const needsPermission = typeof DeviceOrientationEvent !== 'undefined'
&& typeof DeviceOrientationEvent.requestPermission === 'function';
async function enableGyro() {
if (!supportsOrientation) {
console.warn('Brak wsparcia dla DeviceOrientationEvent');
return false;
}
if (needsPermission) {
try {
const state = await DeviceOrientationEvent.requestPermission();
if (state !== 'granted') return false;
} catch (err) {
console.error('Odmowa uprawnień:', err);
return false;
}
}
window.addEventListener('deviceorientation', handleOrientation);
return true;
}
document.querySelector('#enable-gyro').addEventListener('click', enableGyro);
Most JS → CSS przez custom properties
Zamiast modyfikować style.transform w handlerze, oddajemy wartości do CSS przez zmienne. CSS sam decyduje, co z tym zrobi — łatwiej później zmienić efekt bez ruszania JS.
const root = document.documentElement;
function handleOrientation(event) {
// Ograniczamy zakres, żeby nie latało po całym ekranie
const beta = clamp(event.beta, -45, 45); // przód-tył
const gamma = clamp(event.gamma, -45, 45); // lewo-prawo
// Normalizacja do -1..1
const tiltX = gamma / 45;
const tiltY = beta / 45;
root.style.setProperty('--tilt-x', tiltX.toFixed(3));
root.style.setProperty('--tilt-y', tiltY.toFixed(3));
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
CSS używa tych zmiennych, np. do warstwowego parallaxu:
.card {
--tilt-x: 0;
--tilt-y: 0;
--depth: 20px;
transform:
perspective(800px)
rotateY(calc(var(--tilt-x) * 8deg))
rotateX(calc(var(--tilt-y) * -8deg));
transition: transform 0.1s ease-out;
}
.card .layer-back { transform: translate3d(calc(var(--tilt-x) * -10px), calc(var(--tilt-y) * -10px), 0); }
.card .layer-mid { transform: translate3d(calc(var(--tilt-x) * -20px), calc(var(--tilt-y) * -20px), 0); }
.card .layer-front { transform: translate3d(calc(var(--tilt-x) * -30px), calc(var(--tilt-y) * -30px), 0); }
Każda warstwa przesuwa się trochę inaczej — bliższa szybciej, dalsza wolniej. Klasyczny parallax.
Wygładzanie skoków
Surowe dane z żyroskopu skaczą — co kilkadziesiąt milisekund przychodzi nowa próbka i animacja drga. Najprostsze rozwiązanie to filtr dolnoprzepustowy (low-pass), czyli średnia ważona poprzedniej i nowej wartości:
let smoothX = 0;
let smoothY = 0;
const SMOOTHING = 0.15; // 0 = brak ruchu, 1 = brak wygładzania
function handleOrientation(event) {
const targetX = clamp(event.gamma, -45, 45) / 45;
const targetY = clamp(event.beta, -45, 45) / 45;
smoothX += (targetX - smoothX) * SMOOTHING;
smoothY += (targetY - smoothY) * SMOOTHING;
root.style.setProperty('--tilt-x', smoothX.toFixed(3));
root.style.setProperty('--tilt-y', smoothY.toFixed(3));
}
Niższa wartość SMOOTHING = bardziej leniwe podążanie, mniej drgań. 0.1–0.2 to zwykle dobry punkt startowy.
Throttling — nie aktualizuj częściej niż klatka
Zdarzenie deviceorientation potrafi lecieć z częstotliwością 60+ Hz. Nie ma sensu nadpisywać CSS variables częściej niż renderuje się klatka — synchronizujemy z requestAnimationFrame:
let latestEvent = null;
let frameRequested = false;
function handleOrientation(event) {
latestEvent = event;
if (!frameRequested) {
frameRequested = true;
requestAnimationFrame(applyOrientation);
}
}
function applyOrientation() {
frameRequested = false;
if (!latestEvent) return;
const targetX = clamp(latestEvent.gamma, -45, 45) / 45;
const targetY = clamp(latestEvent.beta, -45, 45) / 45;
smoothX += (targetX - smoothX) * SMOOTHING;
smoothY += (targetY - smoothY) * SMOOTHING;
root.style.setProperty('--tilt-x', smoothX.toFixed(3));
root.style.setProperty('--tilt-y', smoothY.toFixed(3));
}
Fallback dla desktopu i osób bez czujnika
Po pierwsze — szanuj prefers-reduced-motion:
@media (prefers-reduced-motion: reduce) {
.card {
transform: none;
transition: none;
}
}
Po drugie, na desktopie warto dać sterowanie myszą jako alternatywę:
if (!supportsOrientation || window.matchMedia('(hover: hover)').matches) {
document.addEventListener('mousemove', (e) => {
const x = (e.clientX / window.innerWidth - 0.5) * 2;
const y = (e.clientY / window.innerHeight - 0.5) * 2;
root.style.setProperty('--tilt-x', x.toFixed(3));
root.style.setProperty('--tilt-y', y.toFixed(3));
});
}
(hover: hover) jest dobrym proxy na „to jest desktop" — telefony i tablety zwracają (hover: none).
Pułapki, na które warto uważać
- Orientacja ekranu. Jeśli telefon obróci się do landscape,
betaigammazamieniają się rolami. Da się to wykryć przezscreen.orientation.typei odpowiednio przemapować osie. - Kalibracja. Telefon w ręce nie leży idealnie poziomo. Warto na starcie zapamiętać początkowe
beta/gammai odejmować je od kolejnych odczytów — wtedy zerem jest „pozycja, w której użytkownik trzymał telefon, gdy włączał efekt". - Bateria. Czujnik kosztuje.
removeEventListenerprzy ukryciu strony (visibilitychange) to dobra praktyka. alphana iOS jest bezużyteczna bez kalibracji — Safari nie zwraca wartości względem północy bez wyraźnej prośby (webkitCompassHeading). Do parallaxu i tak wystarcząbeta/gamma.
Co się dzieje pod spodem
deviceorientation to wynik fuzji danych z trzech czujników: żyroskopu (prędkość kątowa), akcelerometru (grawitacja) i magnetometru (kompas). System operacyjny robi sensor fusion i podaje przeglądarce gotową orientację w stopniach Eulera. To dlatego dane są w miarę stabilne mimo że surowy żyroskop dryfuje — magnetometr i akcelerometr go korygują.
Custom properties CSS są aktualizowane z poziomu JS-a, ale samo renderowanie transform leci na GPU — stąd płynność nawet przy wielu warstwach. Kluczowe jest, żeby animować transform i opacity, nie top/left/width — te ostatnie wywołują reflow i animacja staje się klatkowana.