7 posts 31 tags 7 domains

Ż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.

javascript
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.

javascript
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:

css
.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:

javascript
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:

javascript
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:

css
@media (prefers-reduced-motion: reduce) {
  .card {
    transform: none;
    transition: none;
  }
}

Po drugie, na desktopie warto dać sterowanie myszą jako alternatywę:

javascript
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, beta i gamma zamieniają się rolami. Da się to wykryć przez screen.orientation.type i odpowiednio przemapować osie.
  • Kalibracja. Telefon w ręce nie leży idealnie poziomo. Warto na starcie zapamiętać początkowe beta/gamma i odejmować je od kolejnych odczytów — wtedy zerem jest „pozycja, w której użytkownik trzymał telefon, gdy włączał efekt".
  • Bateria. Czujnik kosztuje. removeEventListener przy ukryciu strony (visibilitychange) to dobra praktyka.
  • alpha na 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.