diff --git a/app/assets/javascripts/blob/3d_viewer/index.js b/app/assets/javascripts/blob/3d_viewer/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..68d4ddad551c7be89e86bcd61f203a7c57e0152a
--- /dev/null
+++ b/app/assets/javascripts/blob/3d_viewer/index.js
@@ -0,0 +1,147 @@
+import * as THREE from 'three/build/three.module';
+import STLLoaderClass from 'three-stl-loader';
+import OrbitControlsClass from 'three-orbit-controls';
+import MeshObject from './mesh_object';
+
+const STLLoader = STLLoaderClass(THREE);
+const OrbitControls = OrbitControlsClass(THREE);
+
+export default class Renderer {
+  constructor(container) {
+    this.renderWrapper = this.render.bind(this);
+    this.objects = [];
+
+    this.container = container;
+    this.width = this.container.offsetWidth;
+    this.height = 500;
+
+    this.loader = new STLLoader();
+
+    this.fov = 45;
+    this.camera = new THREE.PerspectiveCamera(
+      this.fov,
+      this.width / this.height,
+      1,
+      1000,
+    );
+
+    this.scene = new THREE.Scene();
+
+    this.scene.add(this.camera);
+
+    // Setup the viewer
+    this.setupRenderer();
+    this.setupGrid();
+    this.setupLight();
+
+    // Setup OrbitControls
+    this.controls = new OrbitControls(
+      this.camera,
+      this.renderer.domElement,
+    );
+    this.controls.minDistance = 5;
+    this.controls.maxDistance = 30;
+    this.controls.enableKeys = false;
+
+    this.loadFile();
+  }
+
+  setupRenderer() {
+    this.renderer = new THREE.WebGLRenderer({
+      antialias: true,
+    });
+
+    this.renderer.setClearColor(0xFFFFFF);
+    this.renderer.setPixelRatio(window.devicePixelRatio);
+    this.renderer.setSize(
+      this.width,
+      this.height,
+    );
+  }
+
+  setupLight() {
+    // Point light illuminates the object
+    const pointLight = new THREE.PointLight(
+      0xFFFFFF,
+      2,
+      0,
+    );
+
+    pointLight.castShadow = true;
+
+    this.camera.add(pointLight);
+
+    // Ambient light illuminates the scene
+    const ambientLight = new THREE.AmbientLight(
+      0xFFFFFF,
+      1,
+    );
+    this.scene.add(ambientLight);
+  }
+
+  setupGrid() {
+    this.grid = new THREE.GridHelper(
+      20,
+      20,
+      0x000000,
+      0x000000,
+    );
+
+    this.scene.add(this.grid);
+  }
+
+  loadFile() {
+    this.loader.load(this.container.dataset.endpoint, (geo) => {
+      const obj = new MeshObject(geo);
+
+      this.objects.push(obj);
+      this.scene.add(obj);
+
+      this.start();
+      this.setDefaultCameraPosition();
+    });
+  }
+
+  start() {
+    // Empty the container first
+    this.container.innerHTML = '';
+
+    // Add to DOM
+    this.container.appendChild(this.renderer.domElement);
+
+    // Make controls visible
+    this.container.parentNode.classList.remove('is-stl-loading');
+
+    this.render();
+  }
+
+  render() {
+    this.renderer.render(
+      this.scene,
+      this.camera,
+    );
+
+    requestAnimationFrame(this.renderWrapper);
+  }
+
+  changeObjectMaterials(type) {
+    this.objects.forEach((obj) => {
+      obj.changeMaterial(type);
+    });
+  }
+
+  setDefaultCameraPosition() {
+    const obj = this.objects[0];
+    const radius = (obj.geometry.boundingSphere.radius / 1.5);
+    const dist = radius / (Math.sin((this.fov * (Math.PI / 180)) / 2));
+
+    this.camera.position.set(
+      0,
+      dist + 1,
+      dist,
+    );
+
+    this.camera.lookAt(this.grid);
+    this.controls.update();
+  }
+}
diff --git a/app/assets/javascripts/blob/3d_viewer/mesh_object.js b/app/assets/javascripts/blob/3d_viewer/mesh_object.js
new file mode 100644
index 0000000000000000000000000000000000000000..96758884abf2b6972e7c07ddd1137d2b308ba8db
--- /dev/null
+++ b/app/assets/javascripts/blob/3d_viewer/mesh_object.js
@@ -0,0 +1,49 @@
+import {
+  Matrix4,
+  MeshLambertMaterial,
+  Mesh,
+} from 'three/build/three.module';
+
+const defaultColor = 0xE24329;
+const materials = {
+  default: new MeshLambertMaterial({
+    color: defaultColor,
+  }),
+  wireframe: new MeshLambertMaterial({
+    color: defaultColor,
+    wireframe: true,
+  }),
+};
+
+export default class MeshObject extends Mesh {
+  constructor(geo) {
+    super(
+      geo,
+      materials.default,
+    );
+
+    this.geometry.computeBoundingSphere();
+
+    this.rotation.set(-Math.PI / 2, 0, 0);
+
+    if (this.geometry.boundingSphere.radius > 4) {
+      const scale = 4 / this.geometry.boundingSphere.radius;
+
+      this.geometry.applyMatrix(
+        new Matrix4().makeScale(
+          scale,
+          scale,
+          scale,
+        ),
+      );
+      this.geometry.computeBoundingSphere();
+
+      this.position.x = -this.geometry.boundingSphere.center.x;
+      this.position.z = this.geometry.boundingSphere.center.y;
+    }
+  }
+
+  changeMaterial(type) {
+    this.material = materials[type];
+  }
+}
diff --git a/app/assets/javascripts/blob/stl_viewer.js b/app/assets/javascripts/blob/stl_viewer.js
new file mode 100644
index 0000000000000000000000000000000000000000..f611c4fe640b217419677c1a22387508429e2670
--- /dev/null
+++ b/app/assets/javascripts/blob/stl_viewer.js
@@ -0,0 +1,19 @@
+import Renderer from './3d_viewer';
+
+document.addEventListener('DOMContentLoaded', () => {
+  const viewer = new Renderer(document.getElementById('js-stl-viewer'));
+
+  [].slice.call(document.querySelectorAll('.js-material-changer')).forEach((el) => {
+    el.addEventListener('click', (e) => {
+      const target = e.target;
+
+      e.preventDefault();
+
+      document.querySelector('.js-material-changer.active').classList.remove('active');
+      target.classList.add('active');
+      target.blur();
+
+      viewer.changeObjectMaterials(target.dataset.type);
+    });
+  });
+});
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index ffece53a0936e73a996abfbced13639d143073af..ddea1cf540b4aa6d0324e6718b6ef9c369c90000 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -275,3 +275,9 @@ span.idiff {
     }
   }
 }
+
+.is-stl-loading {
+  .stl-controls {
+    display: none;
+  }
+}
diff --git a/app/models/blob.rb b/app/models/blob.rb
index f82126f8e65e74f8d9afcda6f57f10839f15f351..801d344280367e7da09afe43915cb0019f68c5ee 100644
--- a/app/models/blob.rb
+++ b/app/models/blob.rb
@@ -58,6 +58,10 @@ class Blob < SimpleDelegator
     binary? && extname.downcase.delete('.') == 'sketch'
   end
 
+  def stl?
+    extname.downcase.delete('.') == 'stl'
+  end
+
   def size_within_svg_limits?
     size <= MAXIMUM_SVG_SIZE
   end
@@ -81,6 +85,8 @@ class Blob < SimpleDelegator
       'notebook'
     elsif sketch?
       'sketch'
+    elsif stl?
+      'stl'
     elsif text?
       'text'
     else
diff --git a/app/views/projects/blob/_stl.html.haml b/app/views/projects/blob/_stl.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..a9332a0eeb6f7fc99c9ae1b11308812e48b65482
--- /dev/null
+++ b/app/views/projects/blob/_stl.html.haml
@@ -0,0 +1,12 @@
+- content_for :page_specific_javascripts do
+  = page_specific_javascript_bundle_tag('stl_viewer')
+
+.file-content.is-stl-loading
+  .text-center#js-stl-viewer{ data: { endpoint: namespace_project_raw_path(@project.namespace, @project, @id) } }
+    = icon('spinner spin 2x', class: 'prepend-top-default append-bottom-default', 'aria-hidden' => 'true', 'aria-label' => 'Loading')
+  .text-center.prepend-top-default.append-bottom-default.stl-controls
+    .btn-group
+      %button.btn.btn-default.btn-sm.js-material-changer{ data: { type: 'wireframe' } }
+        Wireframe
+      %button.btn.btn-default.btn-sm.active.js-material-changer{ data: { type: 'default' } }
+        Solid
diff --git a/config/webpack.config.js b/config/webpack.config.js
index 69d8c5640f79ab18520337d2a3008f1b0ae7b3a5..dc431e4d566c256cb3a48f8675ba32a234408ef7 100644
--- a/config/webpack.config.js
+++ b/config/webpack.config.js
@@ -42,6 +42,7 @@ var config = {
     profile:              './profile/profile_bundle.js',
     protected_branches:   './protected_branches/protected_branches_bundle.js',
     snippet:              './snippet/snippet_bundle.js',
+    stl_viewer:           './blob/stl_viewer.js',
     terminal:             './terminal/terminal_bundle.js',
     u2f:                  ['vendor/u2f'],
     users:                './users/users_bundle.js',
diff --git a/package.json b/package.json
index 3f64d65d57ad72a054b8c5cc26b866b495a6bf81..312e38f7407f54dafbe220c47c84a9f21382d5dc 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,9 @@
     "raw-loader": "^0.5.1",
     "select2": "3.5.2-browserify",
     "stats-webpack-plugin": "^0.4.3",
+    "three": "^0.84.0",
+    "three-orbit-controls": "^82.1.0",
+    "three-stl-loader": "^1.0.4",
     "timeago.js": "^2.0.5",
     "underscore": "^1.8.3",
     "visibilityjs": "^1.2.4",
diff --git a/spec/javascripts/blob/3d_viewer/mesh_object_spec.js b/spec/javascripts/blob/3d_viewer/mesh_object_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..d1ebae33dabe07cd431f97b345db4957e25666db
--- /dev/null
+++ b/spec/javascripts/blob/3d_viewer/mesh_object_spec.js
@@ -0,0 +1,42 @@
+import {
+  BoxGeometry,
+} from 'three/build/three.module';
+import MeshObject from '~/blob/3d_viewer/mesh_object';
+
+describe('Mesh object', () => {
+  it('defaults to non-wireframe material', () => {
+    const object = new MeshObject(
+      new BoxGeometry(10, 10, 10),
+    );
+
+    expect(object.material.wireframe).toBeFalsy();
+  });
+
+  it('changes to wirefame material', () => {
+    const object = new MeshObject(
+      new BoxGeometry(10, 10, 10),
+    );
+
+    object.changeMaterial('wireframe');
+
+    expect(object.material.wireframe).toBeTruthy();
+  });
+
+  it('scales object down', () => {
+    const object = new MeshObject(
+      new BoxGeometry(10, 10, 10),
+    );
+    const radius = object.geometry.boundingSphere.radius;
+
+    expect(radius).not.toBeGreaterThan(4);
+  });
+
+  it('does not scale object down', () => {
+    const object = new MeshObject(
+      new BoxGeometry(1, 1, 1),
+    );
+    const radius = object.geometry.boundingSphere.radius;
+
+    expect(radius).toBeLessThan(1);
+  });
+});
diff --git a/spec/models/blob_spec.rb b/spec/models/blob_spec.rb
index 09b1fda379689a72b87f658e1f21c7ac6dd35303..0f29766db41f56f3f6993fb66221597c8ad1fe1b 100644
--- a/spec/models/blob_spec.rb
+++ b/spec/models/blob_spec.rb
@@ -111,6 +111,20 @@ describe Blob do
     end
   end
 
+  describe '#stl?' do
+    it 'is falsey with image extension' do
+      git_blob = Gitlab::Git::Blob.new(name: 'file.png')
+
+      expect(described_class.decorate(git_blob)).not_to be_stl
+    end
+
+    it 'is truthy with STL extension' do
+      git_blob = Gitlab::Git::Blob.new(name: 'file.stl')
+
+      expect(described_class.decorate(git_blob)).to be_stl
+    end
+  end
+
   describe '#to_partial_path' do
     let(:project) { double(lfs_enabled?: true) }
 
@@ -122,7 +136,8 @@ describe Blob do
         lfs_pointer?: false,
         svg?: false,
         text?: false,
-        binary?: false
+        binary?: false,
+        stl?: false
       )
 
       described_class.decorate(double).tap do |blob|
@@ -175,6 +190,11 @@ describe Blob do
       blob = stubbed_blob(text?: true, sketch?: true, binary?: true)
       expect(blob.to_partial_path(project)).to eq 'sketch'
     end
+
+    it 'handles STLs' do
+      blob = stubbed_blob(text?: true, stl?: true)
+      expect(blob.to_partial_path(project)).to eq 'stl'
+    end
   end
 
   describe '#size_within_svg_limits?' do
diff --git a/yarn.lock b/yarn.lock
index 65eef75af1a5a36a6d46d1865d4abb6e8fca173b..9f2b8fe3d6ecfbb06eb6b0e882a362814fc90082 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4305,6 +4305,18 @@ text-table@~0.2.0:
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
 
+three-orbit-controls@^82.1.0:
+  version "82.1.0"
+  resolved "https://registry.yarnpkg.com/three-orbit-controls/-/three-orbit-controls-82.1.0.tgz#11a7f33d0a20ecec98f098b37780f6537374fab4"
+
+three-stl-loader@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/three-stl-loader/-/three-stl-loader-1.0.4.tgz#6b3319a31e3b910aab1883d19b00c81a663c3e03"
+
+three@^0.84.0:
+  version "0.84.0"
+  resolved "https://registry.yarnpkg.com/three/-/three-0.84.0.tgz#95be85a55a0fa002aa625ed559130957dcffd918"
+
 throttleit@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-1.0.0.tgz#9e785836daf46743145a5984b6268d828528ac6c"