diff --git a/src/components/pitch-yaw-rotator.js b/src/components/pitch-yaw-rotator.js
index f4c5f684ef5bd42cdb4d006464b1160f104de244..297bf4f81b5105ca229a5d34bdf527b2389725bb 100644
--- a/src/components/pitch-yaw-rotator.js
+++ b/src/components/pitch-yaw-rotator.js
@@ -12,6 +12,13 @@ AFRAME.registerComponent("pitch-yaw-rotator", {
   init() {
     this.pitch = 0;
     this.yaw = 0;
+    this.onRotateX = this.onRotateX.bind(this);
+    this.el.sceneEl.addEventListener("rotateX", this.onRotateX);
+    this.pendingXRotation = 0;
+  },
+
+  onRotateX(e) {
+    this.pendingXRotation += e.detail.value;
   },
 
   look(deltaPitch, deltaYaw) {
@@ -30,10 +37,17 @@ AFRAME.registerComponent("pitch-yaw-rotator", {
   tick() {
     const userinput = AFRAME.scenes[0].systems.userinput;
     const cameraDelta = userinput.readFrameValueAtPath(paths.actions.cameraDelta);
+    let lookX = this.pendingXRotation;
+    let lookY = 0;
     if (cameraDelta) {
-      this.look(cameraDelta[1], cameraDelta[0]);
+      lookY += cameraDelta[0];
+      lookX += cameraDelta[1];
+    }
+    if (lookX !== 0 || lookY !== 0) {
+      this.look(lookX, lookY);
       this.el.object3D.rotation.set(degToRad(this.pitch), degToRad(this.yaw), 0);
       this.el.object3D.rotation.order = "YXZ";
     }
+    this.pendingXRotation = 0;
   }
 });
diff --git a/src/systems/userinput/bindings/touchscreen-user.js b/src/systems/userinput/bindings/touchscreen-user.js
index 92ef99eab630a7677d94a8df7eebe1ca10b22698..899781f40fb41c51ec66daf238296cc2219d1f21 100644
--- a/src/systems/userinput/bindings/touchscreen-user.js
+++ b/src/systems/userinput/bindings/touchscreen-user.js
@@ -4,6 +4,14 @@ import { xforms } from "./xforms";
 
 const zero = "/vars/touchscreen/zero";
 const forward = "/vars/touchscreen/pinchDeltaForward";
+const touchCamDelta = "vars/touchscreen/touchCameraDelta";
+const touchCamDeltaX = "vars/touchscreen/touchCameraDelta/x";
+const touchCamDeltaY = "vars/touchscreen/touchCameraDelta/y";
+const touchCamDeltaXScaled = "vars/touchscreen/touchCameraDelta/x/scaled";
+const touchCamDeltaYScaled = "vars/touchscreen/touchCameraDelta/y/scaled";
+const gyroCamDelta = "vars/gyro/gyroCameraDelta";
+const gyroCamDeltaXScaled = "vars/gyro/gyroCameraDelta/x/scaled";
+const gyroCamDeltaYScaled = "vars/gyro/gyroCameraDelta/y/scaled";
 
 export const touchscreenUserBindings = {
   [sets.global]: [
@@ -27,25 +35,48 @@ export const touchscreenUserBindings = {
       xform: xforms.copy
     },
     {
-      src: { value: paths.device.touchscreen.cameraDelta },
-      dest: { x: "/var/touchscreenCamDeltaX", y: "/var/touchscreenCamDeltaY" },
+      src: { value: paths.device.touchscreen.touchCameraDelta },
+      dest: { x: touchCamDeltaX, y: touchCamDeltaY },
       xform: xforms.split_vec2
     },
     {
-      src: { value: "/var/touchscreenCamDeltaX" },
-      dest: { value: "/var/touchscreenCamDeltaXScaled" },
+      src: { value: touchCamDeltaX },
+      dest: { value: touchCamDeltaXScaled },
       xform: xforms.scale(0.18)
     },
     {
-      src: { value: "/var/touchscreenCamDeltaY" },
-      dest: { value: "/var/touchscreenCamDeltaYScaled" },
+      src: { value: touchCamDeltaY },
+      dest: { value: touchCamDeltaYScaled },
       xform: xforms.scale(0.35)
     },
     {
-      src: { x: "/var/touchscreenCamDeltaXScaled", y: "/var/touchscreenCamDeltaYScaled" },
-      dest: { value: paths.actions.cameraDelta },
+      src: { x: touchCamDeltaXScaled, y: touchCamDeltaYScaled },
+      dest: { value: touchCamDelta },
       xform: xforms.compose_vec2
     },
+    {
+      src: { value: paths.device.gyro.averageDeltaX },
+      dest: { value: gyroCamDeltaXScaled },
+      xform: xforms.scale(1.00)
+    },
+    {
+      src: { value: paths.device.gyro.averageDeltaY },
+      dest: { value: gyroCamDeltaYScaled },
+      xform: xforms.scale(1.00)
+    },
+    {
+      src: { x: gyroCamDeltaYScaled, y: gyroCamDeltaXScaled },
+      dest: { value: gyroCamDelta },
+      xform: xforms.compose_vec2
+    },
+    {
+      src: {
+        first: touchCamDelta,
+        second: gyroCamDelta
+      },
+      dest: { value: paths.actions.cameraDelta },
+      xform: xforms.add_vec2
+    },
     {
       src: { value: paths.device.touchscreen.isTouchingGrabbable },
       dest: { value: paths.actions.cursor.grab },
diff --git a/src/systems/userinput/bindings/xforms.js b/src/systems/userinput/bindings/xforms.js
index 8fe0d49d22ed58513bd50cf65fb8e7a104905b41..444134675a842316f0f5b52f55ed8ff30083ca15 100644
--- a/src/systems/userinput/bindings/xforms.js
+++ b/src/systems/userinput/bindings/xforms.js
@@ -98,6 +98,10 @@ export const xforms = {
     const second = frame[src.second];
     if (first && second) {
       frame[dest.value] = [first[0] + second[0], first[1] + second[1]];
+    } else if (second) {
+      frame[dest.value] = second;
+    } else if (first) {
+      frame[dest.value] = first;
     }
   },
   any: function(frame, src, dest) {
diff --git a/src/systems/userinput/devices/app-aware-touchscreen.js b/src/systems/userinput/devices/app-aware-touchscreen.js
index ab30d2e2528f3f88f5596abcce207c2c276db353..3183ea6c053ab26d225f68eddd820b0a4be44b0c 100644
--- a/src/systems/userinput/devices/app-aware-touchscreen.js
+++ b/src/systems/userinput/devices/app-aware-touchscreen.js
@@ -94,7 +94,9 @@ export class AppAwareTouchscreenDevice {
 
   move(touch) {
     if (!touchIsAssigned(touch, this.assignments)) {
-      console.warn("touch does not have job", touch);
+      if (!touch.target.classList[0] || !touch.target.classList[0].startsWith("virtual-gamepad-controls")) {
+        console.warn("touch does not have job", touch);
+      }
       return;
     }
 
@@ -232,7 +234,7 @@ export class AppAwareTouchscreenDevice {
     }
 
     if (jobIsAssigned(MOVE_CAMERA_JOB, this.assignments)) {
-      frame[path.cameraDelta] = findByJob(MOVE_CAMERA_JOB, this.assignments).delta;
+      frame[path.touchCameraDelta] = findByJob(MOVE_CAMERA_JOB, this.assignments).delta;
     }
 
     frame[path.pinch.delta] = this.pinch.delta;
diff --git a/src/systems/userinput/devices/gyro.js b/src/systems/userinput/devices/gyro.js
new file mode 100644
index 0000000000000000000000000000000000000000..9e36ad5c6628d0fe53c35968d1fcb645a6ccd34e
--- /dev/null
+++ b/src/systems/userinput/devices/gyro.js
@@ -0,0 +1,79 @@
+import { paths } from "../paths";
+
+const TWOPI = Math.PI * 2;
+
+class CircularBuffer {
+  constructor(length) {
+    this.items = new Array(length).fill(0);
+    this.writePtr = 0;
+  }
+
+  push(item) {
+    this.items[this.writePtr] = item;
+    this.writePtr = (this.writePtr + 1) % this.items.length;
+  }
+}
+
+const abs = Math.abs;
+// Input: two numbers between [-Math.PI, Math.PI]
+// Output: difference between them, where -Math.PI === Math.PI
+const difference = (curr, prev) => {
+  const a = curr - prev;
+  const b = curr + TWOPI - prev;
+  const c = curr - (prev + TWOPI);
+  if (abs(a) < abs(b)) {
+    if (abs(a) < abs(c)) {
+      return a;
+    }
+  }
+  if (abs(b) < abs(c)) {
+    return b;
+  }
+
+  return c;
+};
+
+const average = a => {
+  let sum = 0;
+  for (let i = 0; i < a.length; i++) {
+    const n = a[i];
+    sum += n;
+  }
+  return sum / a.length;
+};
+
+export class GyroDevice {
+  constructor() {
+    this.hmdEuler = new THREE.Euler();
+    this.hmdQuaternion = new THREE.Quaternion();
+    this.prevX = this.hmdEuler.x;
+    this.prevY = this.hmdEuler.y;
+    this.dXBuffer = new CircularBuffer(6);
+    this.dYBuffer = new CircularBuffer(6);
+    this.vrDisplay = window.webvrpolyfill.getPolyfillDisplays()[0];
+    this.frameData = new window.webvrpolyfill.constructor.VRFrameData();
+  }
+
+  write(frame) {
+    const hmdEuler = this.hmdEuler;
+    this.vrDisplay.getFrameData(this.frameData);
+    if (this.frameData.pose.orientation !== null) {
+      this.hmdQuaternion.fromArray(this.frameData.pose.orientation);
+      hmdEuler.setFromQuaternion(this.hmdQuaternion, "YXZ");
+    }
+
+    const dX = THREE.Math.RAD2DEG * difference(hmdEuler.x, this.prevX);
+    const dY = THREE.Math.RAD2DEG * difference(hmdEuler.y, this.prevY);
+
+    this.dXBuffer.push(Math.abs(dX) < 0.001 ? 0 : dX);
+    this.dYBuffer.push(Math.abs(dY) < 0.001 ? 0 : dY);
+
+    this.averageDeltaX = average(this.dXBuffer.items);
+    this.averageDeltaY = average(this.dYBuffer.items);
+
+    this.prevX = hmdEuler.x;
+    this.prevY = hmdEuler.y;
+    frame[paths.device.gyro.averageDeltaX] = this.averageDeltaX;
+    frame[paths.device.gyro.averageDeltaY] = this.averageDeltaY;
+  }
+}
diff --git a/src/systems/userinput/paths.js b/src/systems/userinput/paths.js
index 3acb4a740b21462368e08c2674f273b3b78ff849..4afc265bf84f1e89c232399143528815a3770890 100644
--- a/src/systems/userinput/paths.js
+++ b/src/systems/userinput/paths.js
@@ -69,12 +69,17 @@ paths.device.smartMouse.cursorPose = "/device/smartMouse/cursorPose";
 paths.device.smartMouse.cameraDelta = "/device/smartMouse/cameraDelta";
 paths.device.touchscreen = {};
 paths.device.touchscreen.cursorPose = "/device/touchscreen/cursorPose";
+paths.device.touchscreen.touchCameraDelta = "/device/touchscreen/touchCameraDelta";
+paths.device.touchscreen.gyroCameraDelta = "/device/touchscreen/gyroCameraDelta";
 paths.device.touchscreen.cameraDelta = "/device/touchscreen/cameraDelta";
 paths.device.touchscreen.pinch = {};
 paths.device.touchscreen.pinch.delta = "/device/touchscreen/pinch/delta";
 paths.device.touchscreen.pinch.initialDistance = "/device/touchscreen/pinch/initialDistance";
 paths.device.touchscreen.pinch.currentDistance = "/device/touchscreen/pinch/currentDistance";
 paths.device.touchscreen.isTouchingGrabbable = "/device/touchscreen/isTouchingGrabbable";
+paths.device.gyro = {};
+paths.device.gyro.averageDeltaX = "/device/gyro/averageDeltaX";
+paths.device.gyro.averageDeltaY = "/device/gyro/averageDeltaY";
 paths.device.hud = {};
 paths.device.hud.penButton = "/device/hud/penButton";
 
diff --git a/src/systems/userinput/userinput.js b/src/systems/userinput/userinput.js
index de08bea49ed594bf270f4d419436cdf40b9442cd..e7b6abc147704604e038e1af721d9b73f6d37f0b 100644
--- a/src/systems/userinput/userinput.js
+++ b/src/systems/userinput/userinput.js
@@ -9,6 +9,7 @@ import { OculusGoControllerDevice } from "./devices/oculus-go-controller";
 import { OculusTouchControllerDevice } from "./devices/oculus-touch-controller";
 import { DaydreamControllerDevice } from "./devices/daydream-controller";
 import { ViveControllerDevice } from "./devices/vive-controller";
+import { GyroDevice } from "./devices/gyro";
 
 import { AppAwareMouseDevice } from "./devices/app-aware-mouse";
 import { AppAwareTouchscreenDevice } from "./devices/app-aware-touchscreen";
@@ -101,14 +102,17 @@ AFRAME.registerSystem("userinput", {
     this.gamepads = [];
 
     const appAwareTouchscreenDevice = new AppAwareTouchscreenDevice();
+    const gyroDevice = new GyroDevice();
     const updateBindingsForVRMode = () => {
       const inVRMode = this.el.sceneEl.is("vr-mode");
       if (AFRAME.utils.device.isMobile()) {
         if (inVRMode) {
           this.activeDevices.delete(appAwareTouchscreenDevice);
+          this.activeDevices.delete(gyroDevice);
           this.registeredMappings.delete(touchscreenUserBindings);
         } else {
           this.activeDevices.add(appAwareTouchscreenDevice);
+          this.activeDevices.add(gyroDevice);
           this.registeredMappings.add(touchscreenUserBindings);
         }
       } else {