diff --git a/package.json b/package.json
index cae88c47526aef2ed515ceaa4fda82d1a2812622..b1069ad160eb62fedeaa60345ef4cd05384e6482 100644
--- a/package.json
+++ b/package.json
@@ -38,6 +38,7 @@
     "device-detect": "^1.0.7",
     "event-target-shim": "^3.0.1",
     "form-urlencoded": "^2.0.4",
+    "gif-engine-js": "^1.0.1",
     "jsonschema": "^1.2.2",
     "mobile-detect": "^1.4.1",
     "moment": "^2.22.0",
@@ -89,6 +90,7 @@
     "style-loader": "^0.20.2",
     "webpack": "^4.0.1",
     "webpack-cli": "^2.0.9",
-    "webpack-dev-server": "^3.0.0"
+    "webpack-dev-server": "^3.0.0",
+    "worker-loader": "^2.0.0"
   }
 }
diff --git a/src/components/image-plus.js b/src/components/image-plus.js
index 942f022812b261858b5d2326632a8f80959f6986..78189bd53506081ed7dcf9d5b74839d34dc169ff 100644
--- a/src/components/image-plus.js
+++ b/src/components/image-plus.js
@@ -1,3 +1,34 @@
+import GIFWorker from "../workers/gifparsing.worker.js";
+
+class GIFTexture extends THREE.Texture {
+  constructor(frames, delays) {
+    super(frames[0][0]);
+    this.generateMipmaps = false;
+    this.isVideoTexture = true;
+    this.minFilter = THREE.NearestFilter;
+
+    this.frames = frames;
+    this.delays = delays;
+
+    this.frame = 0;
+    this.frameStartTime = Date.now();
+  }
+
+  update() {
+    if (!this.frames || !this.delays) return;
+
+    const now = Date.now();
+
+    if (now - this.frameStartTime > this.delays[this.frame]) {
+      this.frame = (this.frame + 1) % this.frames.length;
+      this.frameStartTime = now;
+      // console.log(this.gifData.frame, this.gifData.frames[this.gifData.frame][0]);
+      this.image = this.frames[this.frame][0];
+      this.needsUpdate = true;
+    }
+  }
+}
+
 AFRAME.registerComponent("image-plus", {
   dependencies: ["geometry", "material"],
 
@@ -68,7 +99,45 @@ AFRAME.registerComponent("image-plus", {
     this.billboardTarget.getWorldQuaternion(this.el.object3D.quaternion);
   },
 
-  update() {
-    this.el.setAttribute("material", "src", this.data.src);
+  async update() {
+    // textureLoader.load(
+    //   getProxyUrl(this.data.src),
+    //   texture => {
+    //     this.el.setAttribute("material", {
+    //       transparent: true,
+    //       src: texture
+    //     });
+    //   },
+    //   function() {
+    //     /* no-op */
+    //   },
+    //   function(xhr) {
+    //     console.error("`$s` could not be fetched (Error code: %s; Response: %s)", xhr.status, xhr.statusText);
+    //   }
+    // );
+
+    const json = await fetch("https://smoke-dev.reticulum.io/api/v1/media", {
+      method: "POST",
+      headers: {
+        "Content-Type": "application/json"
+      },
+      body: JSON.stringify({
+        media: {
+          url: this.data.src
+        }
+      })
+    }).then(r => r.json());
+
+    const rawImageData = await fetch(json.images.raw, { mode: "cors" }).then(r => r.arrayBuffer());
+    const worker = new GIFWorker();
+    worker.onmessage = e => {
+      const [frames, delays, width, height] = e.data;
+      const material = this.el.components.material.material;
+      material.map = new GIFTexture(frames, delays);
+      material.transparent = true;
+      material.needsUpdate = true;
+      this._fit(width, height);
+    };
+    worker.postMessage(rawImageData, [rawImageData]);
   }
 });
diff --git a/src/workers/gifparsing.worker.js b/src/workers/gifparsing.worker.js
new file mode 100644
index 0000000000000000000000000000000000000000..4648f12741abd7350c86e758325be685a0f0d42b
--- /dev/null
+++ b/src/workers/gifparsing.worker.js
@@ -0,0 +1,73 @@
+import { GIF } from "gif-engine-js";
+
+const getDisposals = frameObj => (frameObj.graphicExtension && frameObj.graphicExtension.disposalMethod) || 0;
+const getDelays = frameObj => (frameObj.graphicExtension && frameObj.graphicExtension.delay - 1) || 0;
+const copyColorsTransparent = async (source, target, fWidth, fHeight, oLeft, oTop, cWidth, flag) => {
+  for (let row = 0, pointer = -1; fHeight > row; ++row)
+    for (let column = 0; fWidth > column; ++column) {
+      let offset = (column + oLeft + (row + oTop) * cWidth) * 4;
+      if (flag && source[pointer + 4] === 0) {
+        pointer += 4;
+        continue;
+      }
+      target[offset] = source[++pointer];
+      target[++offset] = source[++pointer];
+      target[++offset] = source[++pointer];
+      ++pointer;
+      target[++offset] = flag ? source[pointer] : 255;
+    }
+};
+const messageHandler = async e => {
+  try {
+    const o = await GIF(e.data);
+    const frameCount = o.frames.length;
+    const compiledFrames = new Array(frameCount);
+    const delays = o.frames.map(getDelays);
+    const canvasWidth = o.descriptor.width;
+    const canvasHeight = o.descriptor.height;
+    const disposals = o.frames.map(getDisposals);
+    const canvas = new Uint8ClampedArray(canvasWidth * canvasHeight * 4);
+    let index = 0;
+    do {
+      const frame = o.frames[index];
+      const transparentColorFlag = frame.graphicExtension && frame.graphicExtension.transparentColorFlag;
+      const [
+        { data: frameImageData, width: frameWidth, height: frameHeight },
+        offsetLeft,
+        offsetTop
+      ] = await o.toImageData(index);
+      await copyColorsTransparent(
+        frameImageData,
+        canvas,
+        frameWidth,
+        frameHeight,
+        offsetLeft,
+        offsetTop,
+        canvasWidth,
+        transparentColorFlag
+      );
+      const a = new Uint8ClampedArray(canvas);
+      compiledFrames[index] = [new ImageData(a, canvasWidth, canvasHeight)];
+      if (disposals[index] === 2) {
+        for (let row = 0; frameHeight > row; ++row) {
+          for (let column = 0; frameWidth > column; ++column) {
+            let offset = (column + offsetLeft + (row + offsetTop) * canvasWidth) * 4;
+            canvas[offset] = 0;
+            canvas[++offset] = 0;
+            canvas[++offset] = 0;
+            canvas[++offset] = transparentColorFlag ? 0 : 255;
+          }
+        }
+      }
+    } while (++index < frameCount);
+    postMessage([compiledFrames, delays, canvasWidth, canvasHeight]);
+  } catch (er) {
+    console.error(er);
+  }
+};
+(global => {
+  global.onmessage = messageHandler;
+  global.onerror = e => {
+    postMessage(["log", e]);
+  };
+})((() => self)());
diff --git a/webpack.config.js b/webpack.config.js
index 6f66a85c9c59fb1cbd5a339519af6eec23987eb7..cd9c6fc47c04b5847dfc9073b1177e6b2cafa90e 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -129,6 +129,10 @@ const config = {
           interpolate: "require"
         }
       },
+      {
+        test: /\.worker\.js$/,
+        use: { loader: "worker-loader" }
+      },
       {
         test: /\.js$/,
         include: [path.resolve(__dirname, "src")],