diff --git a/package-lock.json b/package-lock.json
index 0bbad51e844badf4f715528348442ed89792212a..fef46892ec8d06f32d25cd623b14842a31d87b8e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -518,7 +518,9 @@
       "requires": {
         "@tweenjs/tween.js": "^16.8.0",
         "browserify-css": "^0.8.2",
+        "debug": "github:ngokevin/debug#ef5f8e66d49ce8bc64c6f282c15f8b7164409e3a",
         "deep-assign": "^2.0.0",
+        "document-register-element": "github:dmarcos/document-register-element#8ccc532b7f3744be954574caf3072a5fd260ca90",
         "envify": "^3.4.1",
         "load-bmfont": "^1.2.3",
         "object-assign": "^4.0.1",
@@ -532,11 +534,7 @@
       "dependencies": {
         "debug": {
           "version": "github:ngokevin/debug#ef5f8e66d49ce8bc64c6f282c15f8b7164409e3a",
-          "from": "github:ngokevin/debug#ef5f8e66d49ce8bc64c6f282c15f8b7164409e3a"
-        },
-        "document-register-element": {
-          "version": "github:dmarcos/document-register-element#8ccc532b7f3744be954574caf3072a5fd260ca90",
-          "from": "github:dmarcos/document-register-element#8ccc532b7f3744be954574caf3072a5fd260ca90"
+          "from": "github:ngokevin/debug#noTimestamp"
         },
         "three": {
           "version": "0.94.0",
@@ -3906,6 +3904,10 @@
         "esutils": "^2.0.2"
       }
     },
+    "document-register-element": {
+      "version": "github:dmarcos/document-register-element#8ccc532b7f3744be954574caf3072a5fd260ca90",
+      "from": "github:dmarcos/document-register-element#8ccc532b7"
+    },
     "dom-converter": {
       "version": "0.1.4",
       "resolved": "https://registry.yarnpkg.com/dom-converter/-/dom-converter-0.1.4.tgz",
@@ -10542,6 +10544,12 @@
         }
       }
     },
+    "raw-loader": {
+      "version": "0.5.1",
+      "resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-0.5.1.tgz",
+      "integrity": "sha1-DD0L6u2KAclm2Xh793goElKpeao=",
+      "dev": true
+    },
     "react": {
       "version": "16.4.1",
       "resolved": "https://registry.yarnpkg.com/react/-/react-16.4.1.tgz",
diff --git a/package.json b/package.json
index d9e3d59c08ecdbf2adc92df5046aa149331bae1f..0462a0f4e7fa7fe89b737aefb0f7f210e8a26c88 100644
--- a/package.json
+++ b/package.json
@@ -87,6 +87,7 @@
     "htmlhint": "^0.9.13",
     "node-sass": "^4.9.3",
     "prettier": "^1.7.0",
+    "raw-loader": "^0.5.1",
     "rimraf": "^2.6.2",
     "sass-loader": "^6.0.7",
     "selfsigned": "^1.10.2",
diff --git a/src/components/hover-visuals.js b/src/components/hover-visuals.js
new file mode 100644
index 0000000000000000000000000000000000000000..ec0111e26988b3a7fb10461c03ca496d9bb7c135
--- /dev/null
+++ b/src/components/hover-visuals.js
@@ -0,0 +1,40 @@
+const interactorTransform = [];
+
+/**
+ * Applies effects to a hoverer based on hover state.
+ * @namespace interactables
+ * @component hover-visuals
+ */
+AFRAME.registerComponent("hover-visuals", {
+  schema: {
+    hand: { type: "string" },
+    controller: { type: "selector" }
+  },
+  init() {
+    // uniforms are set from the component responsible for loading the mesh.
+    this.uniforms = null;
+  },
+  remove() {
+    this.uniforms = null;
+  },
+  tick() {
+    if (!this.uniforms || !this.uniforms.size) return;
+
+    this.el.object3D.matrixWorld.toArray(interactorTransform);
+    const hovering = this.data.controller.components["super-hands"].state.has("hover-start");
+
+    for (const uniform of this.uniforms.values()) {
+      if (this.data.hand === "left") {
+        uniform.hubs_HighlightInteractorOne.value = hovering;
+        uniform.hubs_InteractorOnePos.value[0] = interactorTransform[12];
+        uniform.hubs_InteractorOnePos.value[1] = interactorTransform[13];
+        uniform.hubs_InteractorOnePos.value[2] = interactorTransform[14];
+      } else {
+        uniform.hubs_HighlightInteractorTwo.value = hovering;
+        uniform.hubs_InteractorTwoPos.value[0] = interactorTransform[12];
+        uniform.hubs_InteractorTwoPos.value[1] = interactorTransform[13];
+        uniform.hubs_InteractorTwoPos.value[2] = interactorTransform[14];
+      }
+    }
+  }
+});
diff --git a/src/components/hoverable-visuals.js b/src/components/hoverable-visuals.js
new file mode 100644
index 0000000000000000000000000000000000000000..aa33cd4414b611055e592fb520c9b013b945099b
--- /dev/null
+++ b/src/components/hoverable-visuals.js
@@ -0,0 +1,76 @@
+const interactorOneTransform = [];
+const interactorTwoTransform = [];
+
+/**
+ * Applies effects to a hoverable based on hover state.
+ * @namespace interactables
+ * @component hoverable-visuals
+ */
+AFRAME.registerComponent("hoverable-visuals", {
+  schema: {
+    cursorController: { type: "selector" },
+    enableSweepingEffect: { type: "boolean", default: true }
+  },
+  init() {
+    // uniforms and boundingSphere are set from the component responsible for loading the mesh.
+    this.uniforms = null;
+    this.boundingSphere = new THREE.Sphere();
+
+    this.sweepParams = [0, 0];
+  },
+  remove() {
+    this.uniforms = null;
+    this.boundingBox = null;
+  },
+  tick(time) {
+    if (!this.uniforms || !this.uniforms.size) return;
+
+    const { hoverers } = this.el.components["hoverable"];
+
+    let interactorOne, interactorTwo;
+    for (const hoverer of hoverers) {
+      if (hoverer.id === "player-left-controller") {
+        interactorOne = hoverer.object3D;
+      } else if (hoverer.id === "cursor") {
+        if (this.data.cursorController.components["cursor-controller"].enabled) {
+          interactorTwo = hoverer.object3D;
+        }
+      } else {
+        interactorTwo = hoverer.object3D;
+      }
+    }
+
+    if (interactorOne) {
+      interactorOne.matrixWorld.toArray(interactorOneTransform);
+    }
+    if (interactorTwo) {
+      interactorTwo.matrixWorld.toArray(interactorTwoTransform);
+    }
+
+    if (interactorOne || interactorTwo) {
+      const worldY = this.el.object3D.matrixWorld.elements[13];
+      const scaledRadius = this.el.object3D.scale.y * this.boundingSphere.radius;
+      this.sweepParams[0] = worldY - scaledRadius;
+      this.sweepParams[1] = worldY + scaledRadius;
+    }
+
+    for (const uniform of this.uniforms.values()) {
+      uniform.hubs_EnableSweepingEffect.value = this.data.enableSweepingEffect;
+      uniform.hubs_SweepParams.value = this.sweepParams;
+
+      uniform.hubs_HighlightInteractorOne.value = !!interactorOne;
+      uniform.hubs_InteractorOnePos.value[0] = interactorOneTransform[12];
+      uniform.hubs_InteractorOnePos.value[1] = interactorOneTransform[13];
+      uniform.hubs_InteractorOnePos.value[2] = interactorOneTransform[14];
+
+      uniform.hubs_HighlightInteractorTwo.value = !!interactorTwo;
+      uniform.hubs_InteractorTwoPos.value[0] = interactorTwoTransform[12];
+      uniform.hubs_InteractorTwoPos.value[1] = interactorTwoTransform[13];
+      uniform.hubs_InteractorTwoPos.value[2] = interactorTwoTransform[14];
+
+      if (interactorOne || interactorTwo) {
+        uniform.hubs_Time.value = time;
+      }
+    }
+  }
+});
diff --git a/src/components/media-loader.js b/src/components/media-loader.js
index 5dcd47beef202851c03b2cd28d8089875873914f..d3f595cf2250aab50b9b7d9d7d4a134fb613e743 100644
--- a/src/components/media-loader.js
+++ b/src/components/media-loader.js
@@ -1,9 +1,10 @@
 import { getBox, getScaleCoefficient } from "../utils/auto-box-collider";
-import { guessContentType, proxiedUrlFor, resolveUrl } from "../utils/media-utils";
+import { guessContentType, proxiedUrlFor, resolveUrl, injectCustomShaderChunks } from "../utils/media-utils";
 import { addAnimationComponents } from "../utils/animation";
 
 import "three/examples/js/loaders/GLTFLoader";
 import loadingObjectSrc from "../assets/LoadingObject_Atom.glb";
+
 const gltfLoader = new THREE.GLTFLoader();
 let loadingObject;
 gltfLoader.load(loadingObjectSrc, gltf => {
@@ -18,6 +19,8 @@ const fetchMaxContentIndex = url => {
   return fetch(url).then(r => parseInt(r.headers.get("x-max-content-index")));
 };
 
+const boundingBox = new THREE.Box3();
+
 AFRAME.registerComponent("media-loader", {
   schema: {
     src: { type: "string" },
@@ -30,6 +33,7 @@ AFRAME.registerComponent("media-loader", {
     this.onError = this.onError.bind(this);
     this.showLoader = this.showLoader.bind(this);
     this.clearLoadingTimeout = this.clearLoadingTimeout.bind(this);
+    this.onMediaLoaded = this.onMediaLoaded.bind(this);
     this.shapeAdded = false;
     this.hasBakedShapes = false;
   },
@@ -100,6 +104,20 @@ AFRAME.registerComponent("media-loader", {
     delete this.showLoaderTimeout;
   },
 
+  setupHoverableVisuals() {
+    const hoverableVisuals = this.el.components["hoverable-visuals"];
+    if (hoverableVisuals) {
+      hoverableVisuals.uniforms = injectCustomShaderChunks(this.el.object3D);
+      boundingBox.setFromObject(this.el.object3DMap.mesh);
+      boundingBox.getBoundingSphere(hoverableVisuals.boundingSphere);
+    }
+  },
+
+  onMediaLoaded() {
+    this.clearLoadingTimeout();
+    this.setupHoverableVisuals();
+  },
+
   async update(oldData) {
     try {
       const { src } = this.data;
@@ -135,13 +153,13 @@ AFRAME.registerComponent("media-loader", {
       if (contentType.startsWith("video/") || contentType.startsWith("audio/")) {
         this.el.removeAttribute("gltf-model-plus");
         this.el.removeAttribute("media-image");
-        this.el.addEventListener("video-loaded", this.clearLoadingTimeout, { once: true });
+        this.el.addEventListener("video-loaded", this.onMediaLoaded, { once: true });
         this.el.setAttribute("media-video", { src: accessibleUrl });
         this.el.setAttribute("position-at-box-shape-border", { dirs: ["forward", "back"] });
       } else if (contentType.startsWith("image/")) {
         this.el.removeAttribute("gltf-model-plus");
         this.el.removeAttribute("media-video");
-        this.el.addEventListener("image-loaded", this.clearLoadingTimeout, { once: true });
+        this.el.addEventListener("image-loaded", this.onMediaLoaded, { once: true });
         this.el.removeAttribute("media-pager");
         this.el.setAttribute("media-image", { src: accessibleUrl, contentType });
         this.el.setAttribute("position-at-box-shape-border", { dirs: ["forward", "back"] });
@@ -152,7 +170,7 @@ AFRAME.registerComponent("media-loader", {
         // 1. we pass the canonical URL to the pager so it can easily make subresource URLs
         // 2. we don't remove the media-image component -- media-pager uses that internally
         this.el.setAttribute("media-pager", { src: canonicalUrl });
-        this.el.addEventListener("preview-loaded", this.clearLoadingTimeout, { once: true });
+        this.el.addEventListener("preview-loaded", this.onMediaLoaded, { once: true });
         this.el.setAttribute("position-at-box-shape-border", { dirs: ["forward", "back"] });
       } else if (
         contentType.includes("application/octet-stream") ||
@@ -168,6 +186,7 @@ AFRAME.registerComponent("media-loader", {
             this.clearLoadingTimeout();
             this.hasBakedShapes = !!(this.el.body && this.el.body.shapes.length > (this.shapeAdded ? 1 : 0));
             this.setShapeAndScale(this.data.resize);
+            this.setupHoverableVisuals();
             addAnimationComponents(this.el);
           },
           { once: true }
diff --git a/src/components/player-info.js b/src/components/player-info.js
index a7e0812f8810c56544f14f2ed0c93602050f2a13..7c5cbd89e1f97cd0a61cda24187d24a01858284e 100644
--- a/src/components/player-info.js
+++ b/src/components/player-info.js
@@ -1,3 +1,5 @@
+import { injectCustomShaderChunks } from "../utils/media-utils";
+
 /**
  * Sets player info state, including avatar choice and display name.
  * @namespace avatar
@@ -32,5 +34,10 @@ AFRAME.registerComponent("player-info", {
     if (this.data.avatarSrc && modelEl) {
       modelEl.setAttribute("gltf-model-plus", "src", this.data.avatarSrc);
     }
+
+    const uniforms = injectCustomShaderChunks(this.el.object3D);
+    this.el.querySelectorAll("[hover-visuals]").forEach(el => {
+      el.components["hover-visuals"].uniforms = uniforms;
+    });
   }
 });
diff --git a/src/components/super-spawner.js b/src/components/super-spawner.js
index 7ff4e1189f589d9d026ba63e920dfc5d47a78760..c5e557401cf5ea9e57e471e7b83a1b638f8cb4b7 100644
--- a/src/components/super-spawner.js
+++ b/src/components/super-spawner.js
@@ -85,6 +85,8 @@ AFRAME.registerComponent("super-spawner", {
     this.onSpawnEvent = this.onSpawnEvent.bind(this);
 
     this.sceneEl = document.querySelector("a-scene");
+
+    this.el.setAttribute("hoverable-visuals", { cursorController: "#cursor-controller", enableSweepingEffect: false });
   },
 
   play() {
diff --git a/src/hub.html b/src/hub.html
index fc27508c6827eca9eb4e72b2638cd75e8ec5bb61..114e811be94d601afbecb964b990b9a13af46fc1 100644
--- a/src/hub.html
+++ b/src/hub.html
@@ -145,6 +145,7 @@
                     grabbable
                     stretchable="useWorldPosition: true; usePhysics: never"
                     hoverable
+                    hoverable-visuals="cursorController: #cursor-controller"
                     auto-scale-cannon-physics-body
                     sticky-object="autoLockOnRelease: true; autoLockOnLoad: true;"
                     position-at-box-shape-border="target:.delete-button"
@@ -436,11 +437,11 @@
             </template>
 
             <template data-name="LeftHand">
-              <a-entity bone-visibility></a-entity>
+              <a-entity bone-visibility hover-visuals="hand: left; controller: #player-left-controller"></a-entity>
             </template>
 
             <template data-name="RightHand">
-              <a-entity bone-visibility></a-entity>
+              <a-entity bone-visibility hover-visuals="hand: right; controller: #player-right-controller"></a-entity>
             </template>
 
           </a-entity>
diff --git a/src/hub.js b/src/hub.js
index f877d9df0172d74d1f5232ca418e0e033b95f106..5483dfa13afcf125ece5a026b17ac541a07852de 100644
--- a/src/hub.js
+++ b/src/hub.js
@@ -33,6 +33,8 @@ import "./components/virtual-gamepad-controls";
 import "./components/ik-controller";
 import "./components/hand-controls2";
 import "./components/character-controller";
+import "./components/hoverable-visuals";
+import "./components/hover-visuals";
 import "./components/haptic-feedback";
 import "./components/networked-video-player";
 import "./components/offset-relative-to";
diff --git a/src/materials/MobileStandardMaterial.js b/src/materials/MobileStandardMaterial.js
index aa9e10de101be73807c0259e1eb75f222e4d974d..10657605f2818c8ddad3eb723fe1d7dc7c4babac 100644
--- a/src/materials/MobileStandardMaterial.js
+++ b/src/materials/MobileStandardMaterial.js
@@ -74,6 +74,8 @@ void main() {
 `;
 
 export default class MobileStandardMaterial extends THREE.ShaderMaterial {
+  type = "MobileStandardMaterial";
+  isMobileStandardMaterial = true;
   static fromStandardMaterial(material) {
     const parameters = {
       vertexShader: VERTEX_SHADER,
@@ -107,4 +109,7 @@ export default class MobileStandardMaterial extends THREE.ShaderMaterial {
 
     return mobileMaterial;
   }
+  clone() {
+    return MobileStandardMaterial.fromStandardMaterial(this);
+  }
 }
diff --git a/src/utils/media-highlight-frag.glsl b/src/utils/media-highlight-frag.glsl
new file mode 100644
index 0000000000000000000000000000000000000000..82214980d57847926b5a1edbb8c9deef01b4721e
--- /dev/null
+++ b/src/utils/media-highlight-frag.glsl
@@ -0,0 +1,33 @@
+if (hubs_HighlightInteractorOne || hubs_HighlightInteractorTwo) {
+  float ratio = 0.0;
+
+  if (hubs_EnableSweepingEffect) {
+    float size = hubs_SweepParams.t - hubs_SweepParams.s;
+    float line = mod(hubs_Time / 3000.0 * size, size * 2.0) + hubs_SweepParams.s - size / 2.0;
+
+    if (hubs_WorldPosition.y < line) {
+      // Highlight with a sweeping gradient.
+      ratio = max(0.0, 1.0 - (line - hubs_WorldPosition.y) / size * 3.0);
+    }
+  }
+
+  // Highlight with a gradient falling off with distance.
+  float pulse = 9.0 + 3.0 * (sin(hubs_Time / 1000.0) + 1.0);
+
+  if (hubs_HighlightInteractorOne) {
+    float dist1 = distance(hubs_WorldPosition, hubs_InteractorOnePos);
+    ratio += -min(1.0, pow(dist1 * pulse, 3.0)) + 1.0;
+  } 
+
+  if (hubs_HighlightInteractorTwo) {
+    float dist2 = distance(hubs_WorldPosition, hubs_InteractorTwoPos);
+    ratio += -min(1.0, pow(dist2 * pulse, 3.0)) + 1.0;
+  }
+
+  ratio = min(1.0, ratio);
+
+  // Gamma corrected highlight color
+  vec3 highlightColor = vec3(0.184, 0.499, 0.933);
+
+  gl_FragColor.rgb = (gl_FragColor.rgb * (1.0 - ratio)) + (highlightColor * ratio);
+}
diff --git a/src/utils/media-utils.js b/src/utils/media-utils.js
index d72a453fa31707371dced4fb5a0151ee5577634e..4750ba603974604b892f68019f4c5c295a616532 100644
--- a/src/utils/media-utils.js
+++ b/src/utils/media-utils.js
@@ -1,5 +1,7 @@
 import { objectTypeForOriginAndContentType } from "../object-types";
 import { getReticulumFetchUrl } from "./phoenix-utils";
+import mediaHighlightFrag from "./media-highlight-frag.glsl";
+
 const mediaAPIEndpoint = getReticulumFetchUrl("/api/v1/media");
 
 const commonKnownContentTypes = {
@@ -136,3 +138,64 @@ export const addMedia = (src, template, contentOrigin, resolve = false, resize =
 
   return { entity, orientation };
 };
+
+export function injectCustomShaderChunks(obj) {
+  const vertexRegex = /\bskinning_vertex\b/;
+  const fragRegex = /\bgl_FragColor\b/;
+  const validMaterials = ["MeshStandardMaterial", "MeshBasicMaterial", "MobileStandardMaterial"];
+
+  const shaderUniforms = new Map();
+
+  obj.traverse(object => {
+    if (!object.material || !validMaterials.includes(object.material.type)) {
+      return;
+    }
+    object.material = object.material.clone();
+    object.material.onBeforeCompile = shader => {
+      if (!vertexRegex.test(shader.vertexShader)) return;
+
+      shader.uniforms.hubs_EnableSweepingEffect = { value: false };
+      shader.uniforms.hubs_SweepParams = { value: [0, 0] };
+      shader.uniforms.hubs_InteractorOnePos = { value: [0, 0, 0] };
+      shader.uniforms.hubs_InteractorTwoPos = { value: [0, 0, 0] };
+      shader.uniforms.hubs_HighlightInteractorOne = { value: false };
+      shader.uniforms.hubs_HighlightInteractorTwo = { value: false };
+      shader.uniforms.hubs_Time = { value: 0 };
+
+      const vchunk = `
+        if (hubs_HighlightInteractorOne || hubs_HighlightInteractorTwo) {
+          vec4 wt = modelMatrix * vec4(transformed, 1);
+
+          // Used in the fragment shader below.
+          hubs_WorldPosition = wt.xyz;
+        }
+      `;
+
+      const vlines = shader.vertexShader.split("\n");
+      const vindex = vlines.findIndex(line => vertexRegex.test(line));
+      vlines.splice(vindex + 1, 0, vchunk);
+      vlines.unshift("varying vec3 hubs_WorldPosition;");
+      vlines.unshift("uniform bool hubs_HighlightInteractorOne;");
+      vlines.unshift("uniform bool hubs_HighlightInteractorTwo;");
+      shader.vertexShader = vlines.join("\n");
+
+      const flines = shader.fragmentShader.split("\n");
+      const findex = flines.findIndex(line => fragRegex.test(line));
+      flines.splice(findex + 1, 0, mediaHighlightFrag);
+      flines.unshift("varying vec3 hubs_WorldPosition;");
+      flines.unshift("uniform bool hubs_EnableSweepingEffect;");
+      flines.unshift("uniform vec2 hubs_SweepParams;");
+      flines.unshift("uniform bool hubs_HighlightInteractorOne;");
+      flines.unshift("uniform vec3 hubs_InteractorOnePos;");
+      flines.unshift("uniform bool hubs_HighlightInteractorTwo;");
+      flines.unshift("uniform vec3 hubs_InteractorTwoPos;");
+      flines.unshift("uniform float hubs_Time;");
+      shader.fragmentShader = flines.join("\n");
+
+      shaderUniforms.set(object.material.uuid, shader.uniforms);
+    };
+    object.material.needsUpdate = true;
+  });
+
+  return shaderUniforms;
+}
diff --git a/webpack.config.js b/webpack.config.js
index 55afb6708d844a885ade6473327360ca6198c243..e7b6e4d8e3cdc6cd918fb3c66c1fd9718f58f39e 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -77,6 +77,7 @@ module.exports = (env, argv) => ({
   devServer: {
     https: createHTTPSConfig(),
     host: "0.0.0.0",
+    public: "hubs.local:8080",
     useLocalIp: true,
     allowedHosts: ["hubs.local"],
     before: function(app) {
@@ -153,6 +154,10 @@ module.exports = (env, argv) => ({
             context: path.join(__dirname, "src")
           }
         }
+      },
+      {
+        test: /\.(glsl)$/,
+        use: { loader: "raw-loader" }
       }
     ]
   },