Sensors and Haptics

Read the accelerometer and gyroscope with expo-sensors, add haptics that carry meaning, and keep the screen awake.

Syntaxconst subscription = Accelerometer.addListener(({ x, y, z }) => {}); subscription.remove();

expo-sensors exposes the motion hardware. expo-haptics makes the device tap back. Install them with npx expo install expo-sensors expo-haptics.

Reading a sensor

Each sensor has the same shape. Accelerometer.addListener(listener) returns a subscription with a remove() method, and Accelerometer.setUpdateInterval(ms) sets how often readings arrive. isAvailableAsync() resolves to a boolean, because not every device has every sensor. Units matter: accelerometer x, y and z are in g-force, where 1g is 9.81 m/s squared, while gyroscope values are radians per second. Neither is degrees.

Subscribe in an effect and remove in its cleanup. A listener at 16 ms that calls setState re-renders roughly sixty times a second, so either slow the interval or keep readings in a ref and render less often.

Haptics that mean something

Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light) is a collision, with styles Light, Medium, Heavy, Rigid and Soft. Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success) marks an outcome, with Success, Warning and Error. Haptics.selectionAsync() is the small tick of a value changing. Fire them on state changes the user cares about, not on every tap, or they become noise.

Keeping the screen on

useKeepAwake() from expo-keep-awake holds the screen awake while the component that calls it is mounted. The imperative pair activateKeepAwakeAsync(tag) and deactivateKeepAwake(tag) is the same thing by hand, and each tag must be released separately.

Example

Example · tsx
import { useEffect, useRef, useState } from "react";
import { StyleSheet, Text, View } from "react-native";
import { Accelerometer } from "expo-sensors";
import * as Haptics from "expo-haptics";
import { useKeepAwake } from "expo-keep-awake";

export default function Level() {
  useKeepAwake(); // screen stays on while this screen is mounted
  const [tilt, setTilt] = useState(0);
  const wasLevel = useRef(false);

  useEffect(() => {
    Accelerometer.setUpdateInterval(100); // ms between readings
    // ({ x }) => ... is shorthand for (reading) => reading.x
    const subscription = Accelerometer.addListener(({ x }) => {
      setTilt(x); // g-force along the short edge, not degrees
      const level = Math.abs(x) < 0.02;
      // Tap once on crossing into level, not on every reading.
      if (level && !wasLevel.current) {
        Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
      }
      wasLevel.current = level;
    });
    return () => subscription.remove();
  }, []);

  return (
    <View style={styles.screen}>
      <Text style={styles.readout}>{tilt.toFixed(2)} g</Text>
      <Text style={styles.hint}>{Math.abs(tilt) < 0.02 ? "Level" : "Tilted"}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, alignItems: "center", justifyContent: "center", gap: 8 },
  readout: { fontSize: 40, fontVariant: ["tabular-nums"] },
  hint: { fontSize: 16, color: "#6b7280" },
});

When to use it

  • Tick the device as a picker value passes each step under the user's finger.
  • Confirm a completed payment with a success notification haptic.
  • Show a spirit level or a shake-to-undo gesture from accelerometer readings.
  • Keep the screen awake while a recipe, a workout or a scanner is on screen.

More examples

Gyroscope with an availability check

The hooks are called unconditionally at the top and the early return happens after them, which is the rule React enforces. The sensor is only subscribed once isAvailableAsync has resolved true, so a tablet without a gyroscope shows a plain message instead of three frozen zeros. A 200 ms interval is slow enough that the state update does not dominate the frame budget.

Example · tsx
import { useEffect, useState } from "react";
import { StyleSheet, Text, View } from "react-native";
import { Gyroscope } from "expo-sensors";

type Rate = { x: number; y: number; z: number };

export default function RotationReadout() {
  const [available, setAvailable] = useState<boolean | null>(null);
  const [rate, setRate] = useState<Rate>({ x: 0, y: 0, z: 0 });

  useEffect(() => {
    let subscription: { remove: () => void } | null = null;
    let cancelled = false;

    Gyroscope.isAvailableAsync().then((ok) => {
      if (cancelled) return;
      setAvailable(ok);
      if (!ok) return;
      Gyroscope.setUpdateInterval(200);
      subscription = Gyroscope.addListener(({ x, y, z }) => setRate({ x, y, z }));
    });

    return () => {
      cancelled = true;
      subscription?.remove();
    };
  }, []);

  if (available === false) {
    return (
      <View style={styles.screen}>
        <Text>This device has no gyroscope.</Text>
      </View>
    );
  }

  return (
    <View style={styles.screen}>
      <Text style={styles.row}>{`x ${rate.x.toFixed(2)} rad/s`}</Text>
      <Text style={styles.row}>{`y ${rate.y.toFixed(2)} rad/s`}</Text>
      <Text style={styles.row}>{`z ${rate.z.toFixed(2)} rad/s`}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, alignItems: "center", justifyContent: "center", gap: 4 },
  row: { fontSize: 18, fontVariant: ["tabular-nums"] },
});

Converting a reading to something readable

Sensor values are physical quantities, not display values. Keeping the conversion in a plain exported function means the screen stays a thin layer over the subscription, and the maths can be tested in Jest where no accelerometer exists. Reach for this split whenever a sensor feeds a gauge, a compass needle or a threshold.

Example · tsx
// tilt.ts - no UI, so it can be unit tested without a device
export function tiltDegrees(x: number, y: number): number {
  // atan2 gives radians; multiply by 180 / PI for degrees.
  const degrees = (Math.atan2(y, x) * 180) / Math.PI;
  return Math.round(degrees);
}

console.log(tiltDegrees(0, 1));
// Output: 90
console.log(tiltDegrees(-1, 0));
// Output: 180
console.log(tiltDegrees(1, 0));
// Output: 0

A haptic vocabulary in one place

Scattering impactAsync calls through screens produces an app that buzzes constantly and means nothing. Naming the events instead forces a decision about what deserves feedback, and gives you one file to turn down if testers say it is too much. A union type for AppEvent makes the switch exhaustive, so adding an event that nobody handled is a compile error rather than a silent nothing.

Example · tsx
// feedback.ts - every haptic in the app goes through here
import * as Haptics from "expo-haptics";

type AppEvent = "stepChanged" | "saved" | "rejected" | "limitReached";

export async function feedback(event: AppEvent): Promise<void> {
  switch (event) {
    case "stepChanged":
      // The small tick of a value moving under the finger.
      return Haptics.selectionAsync();
    case "saved":
      return Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
    case "rejected":
      return Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
    case "limitReached":
      // A dead stop: the slider will not go further.
      return Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Rigid);
  }
}

Discussion

  • Be the first to comment on this lesson.