diff --git a/src/components/hover-visuals.js b/src/components/hover-visuals.js
new file mode 100644
index 0000000000000000000000000000000000000000..775f51dcab7b986e8446a2673caf3f06695fa905
--- /dev/null
+++ b/src/components/hover-visuals.js
@@ -0,0 +1,26 @@
+/**
+ * Listens for hoverable state changes and applies a visual effect to an entity
+ * @namespace interactables
+ * @component hover-visuals
+ */
+AFRAME.registerComponent("hover-visuals", {
+  init: function() {
+    // uniforms are set from the component responsible for loading the mesh.
+    this.uniforms = null;
+    this.interactorTransform = [];
+  },
+  remove() {
+    this.interactorTransform = null;
+  },
+  tick(time) {
+    if (!this.uniforms) return;
+
+    this.el.object3D.matrixWorld.toArray(this.interactorTransform);
+
+    for (const uniform of this.uniforms) {
+      uniform.hubs_HighlightInteractorOne.value = !!this.el.components["super-hands"].state.has("hover-start");
+      uniform.hubs_InteractorOneTransform.value = this.interactorTransform;
+      uniform.hubs_Time.value = time;
+    }
+  }
+});
diff --git a/src/components/interactables/hoverable-visuals.js b/src/components/hoverable-visuals.js
similarity index 89%
rename from src/components/interactables/hoverable-visuals.js
rename to src/components/hoverable-visuals.js
index 83a43757345ef26f7fc01b2457c06de7441975d0..0728c95bd1825dbaf1dfd23b345c3c5ddf6d9a38 100644
--- a/src/components/interactables/hoverable-visuals.js
+++ b/src/components/hoverable-visuals.js
@@ -8,6 +8,8 @@ AFRAME.registerComponent("hoverable-visuals", {
     cursorController: { type: "selector" }
   },
   init: function() {
+    // uniforms are set from the component responsible for loading the mesh.
+    this.uniforms = null;
     this.interactorOneTransform = [];
     this.interactorTwoTransform = [];
   },
@@ -16,9 +18,7 @@ AFRAME.registerComponent("hoverable-visuals", {
     this.interactorTwoTransform = null;
   },
   tick(time) {
-    const uniforms = this.el.components["media-loader"].shaderUniforms;
-
-    if (!uniforms) return;
+    if (!this.uniforms) return;
 
     const { hoverers } = this.el.components["hoverable"];
 
@@ -42,7 +42,7 @@ AFRAME.registerComponent("hoverable-visuals", {
       interactorTwo.matrixWorld.toArray(this.interactorTwoTransform);
     }
 
-    for (const uniform of uniforms) {
+    for (const uniform of this.uniforms) {
       uniform.hubs_HighlightInteractorOne.value = !!interactorOne;
       uniform.hubs_InteractorOneTransform.value = this.interactorOneTransform;
       uniform.hubs_HighlightInteractorTwo.value = !!interactorTwo;
diff --git a/src/components/media-loader.js b/src/components/media-loader.js
index e9393c7425e3170a92c9b24626b3f12ba975866e..aac0915769903d96776e49f8a0ab2dd67721f48a 100644
--- a/src/components/media-loader.js
+++ b/src/components/media-loader.js
@@ -1,7 +1,6 @@
 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 mediaHighlightFrag from "./media-highlight-frag.glsl";
 
 import "three/examples/js/loaders/GLTFLoader";
 import loadingObjectSrc from "../assets/LoadingObject_Atom.glb";
@@ -20,67 +19,6 @@ const fetchMaxContentIndex = url => {
   return fetch(url).then(r => parseInt(r.headers.get("x-max-content-index")));
 };
 
-function injectCustomShaderChunks(obj) {
-  const vertexRegex = /\bbegin_vertex\b/;
-  const fragRegex = /\bgl_FragColor\b/;
-
-  const materialsSeen = new Set();
-  const shaderUniforms = [];
-
-  obj.traverse(object => {
-    if (!object.material || !["MeshStandardMaterial", "MeshBasicMaterial"].includes(object.material.type)) {
-      return;
-    }
-    object.material = object.material.clone();
-    object.material.onBeforeCompile = shader => {
-      if (!vertexRegex.test(shader.vertexShader)) return;
-
-      shader.uniforms.hubs_InteractorOneTransform = { value: [] };
-      shader.uniforms.hubs_InteractorTwoTransform = { value: [] };
-      shader.uniforms.hubs_InteractorTwoPos = { value: [] };
-      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_HighlightInteractorOne;");
-      flines.unshift("uniform mat4 hubs_InteractorOneTransform;");
-      flines.unshift("uniform bool hubs_HighlightInteractorTwo;");
-      flines.unshift("uniform mat4 hubs_InteractorTwoTransform;");
-      flines.unshift("uniform float hubs_Time;");
-      shader.fragmentShader = flines.join("\n");
-
-      if (!materialsSeen.has(object.material.uuid)) {
-        shaderUniforms.push(shader.uniforms);
-        materialsSeen.add(object.material.uuid);
-      }
-    };
-    object.material.needsUpdate = true;
-  });
-
-  return shaderUniforms;
-}
-
 AFRAME.registerComponent("media-loader", {
   schema: {
     src: { type: "string" },
@@ -166,7 +104,9 @@ AFRAME.registerComponent("media-loader", {
 
   onMediaLoaded() {
     this.clearLoadingTimeout();
-    this.shaderUniforms = injectCustomShaderChunks(this.el.object3D);
+    if (this.el.components["hoverable-visuals"]) {
+      this.el.components["hoverable-visuals"].uniforms = injectCustomShaderChunks(this.el.object3D);
+    }
   },
 
   async update(oldData) {
diff --git a/src/components/player-info.js b/src/components/player-info.js
index a7e0812f8810c56544f14f2ed0c93602050f2a13..612386b4182e22f98356c2f00894d90feecab9c1 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,9 @@ AFRAME.registerComponent("player-info", {
     if (this.data.avatarSrc && modelEl) {
       modelEl.setAttribute("gltf-model-plus", "src", this.data.avatarSrc);
     }
+
+    this.el.querySelectorAll("[hover-visuals]").forEach(el => {
+      el.components["hover-visuals"].uniforms = injectCustomShaderChunks(this.el.object3D);
+    });
   }
 });
diff --git a/src/hub.html b/src/hub.html
index d1fb11feda2bb0b12815e6a32459e864ec4855f5..462512136f0cc2dc7c5d1d2755b91221cb6dbfe3 100644
--- a/src/hub.html
+++ b/src/hub.html
@@ -377,6 +377,7 @@
                   missOpacity: 0.1;
                   curveShootingSpeed: 12;"
               haptic-feedback
+              hover-visuals
               body="type: static; shape: none;"
               mixin="controller-super-hands"
               controls-shape-offset
@@ -403,6 +404,7 @@
                   missOpacity: 0.1;
                   curveShootingSpeed: 12;"
               haptic-feedback
+              hover-visuals
               body="type: static; shape: none;"
               mixin="controller-super-hands"
               controls-shape-offset
diff --git a/src/hub.js b/src/hub.js
index 00e29df3c855fac97341c5f9911c4264ad7dba36..70e2d528fca30e8e79c4bbb1cea684633abede2f 100644
--- a/src/hub.js
+++ b/src/hub.js
@@ -33,7 +33,8 @@ import "./components/virtual-gamepad-controls";
 import "./components/ik-controller";
 import "./components/hand-controls2";
 import "./components/character-controller";
-import "./components/interactables/hoverable-visuals";
+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/components/media-highlight-frag.glsl b/src/utils/media-highlight-frag.glsl
similarity index 100%
rename from src/components/media-highlight-frag.glsl
rename to src/utils/media-highlight-frag.glsl
diff --git a/src/utils/media-utils.js b/src/utils/media-utils.js
index d72a453fa31707371dced4fb5a0151ee5577634e..c192c11107af1ada6bf184e164d9981c221bbcbc 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 = /\bbegin_vertex\b/;
+  const fragRegex = /\bgl_FragColor\b/;
+
+  const materialsSeen = new Set();
+  const shaderUniforms = [];
+
+  obj.traverse(object => {
+    if (!object.material || !["MeshStandardMaterial", "MeshBasicMaterial"].includes(object.material.type)) {
+      return;
+    }
+    object.material = object.material.clone();
+    object.material.onBeforeCompile = shader => {
+      if (!vertexRegex.test(shader.vertexShader)) return;
+
+      shader.uniforms.hubs_InteractorOneTransform = { value: [] };
+      shader.uniforms.hubs_InteractorTwoTransform = { value: [] };
+      shader.uniforms.hubs_InteractorTwoPos = { value: [] };
+      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_HighlightInteractorOne;");
+      flines.unshift("uniform mat4 hubs_InteractorOneTransform;");
+      flines.unshift("uniform bool hubs_HighlightInteractorTwo;");
+      flines.unshift("uniform mat4 hubs_InteractorTwoTransform;");
+      flines.unshift("uniform float hubs_Time;");
+      shader.fragmentShader = flines.join("\n");
+
+      if (!materialsSeen.has(object.material.uuid)) {
+        shaderUniforms.push(shader.uniforms);
+        materialsSeen.add(object.material.uuid);
+      }
+    };
+    object.material.needsUpdate = true;
+  });
+
+  return shaderUniforms;
+}