diff --git a/Gruntfile.js b/Gruntfile.js
index 4182d9d3c3ba818a2acfe4a31ac791482b8e66b9..b3c29c353348a9e59b0e7c187ff30268d465bde8 100644
--- a/Gruntfile.js
+++ b/Gruntfile.js
@@ -63,13 +63,14 @@ module.exports = function (grunt) {
       },
       dist: {
         files: {
-          'js/dist/util.js'     : 'js/src/util.js',
-          'js/dist/alert.js'    : 'js/src/alert.js',
-          'js/dist/button.js'   : 'js/src/button.js',
-          'js/dist/carousel.js' : 'js/src/carousel.js',
-          'js/dist/collapse.js' : 'js/src/collapse.js',
-          'js/dist/dropdown.js' : 'js/src/dropdown.js',
-          'js/dist/modal.js'    : 'js/src/modal.js'
+          'js/dist/util.js'      : 'js/src/util.js',
+          'js/dist/alert.js'     : 'js/src/alert.js',
+          'js/dist/button.js'    : 'js/src/button.js',
+          'js/dist/carousel.js'  : 'js/src/carousel.js',
+          'js/dist/collapse.js'  : 'js/src/collapse.js',
+          'js/dist/dropdown.js'  : 'js/src/dropdown.js',
+          'js/dist/modal.js'     : 'js/src/modal.js',
+          'js/dist/scrollspy.js' : 'js/src/scrollspy.js'
         }
       }
     },
diff --git a/js/dist/scrollspy.js b/js/dist/scrollspy.js
new file mode 100644
index 0000000000000000000000000000000000000000..a0d774f438b9468c79667062563b9417ee489b53
--- /dev/null
+++ b/js/dist/scrollspy.js
@@ -0,0 +1,268 @@
+'use strict';
+
+var _createClass = (function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ('value' in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; })();
+
+function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } }
+
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v4.0.0): scrollspy.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+var ScrollSpy = (function ($) {
+
+  /**
+   * ------------------------------------------------------------------------
+   * Constants
+   * ------------------------------------------------------------------------
+   */
+
+  var NAME = 'scrollspy';
+  var VERSION = '4.0.0';
+  var DATA_KEY = 'bs.scrollspy';
+  var JQUERY_NO_CONFLICT = $.fn[NAME];
+  var TRANSITION_DURATION = 150;
+
+  var Defaults = {
+    offset: 10
+  };
+
+  var Event = {
+    ACTIVATE: 'activate.bs.scrollspy',
+    SCROLL: 'scroll.bs.scrollspy',
+    LOAD: 'load.bs.scrollspy.data-api'
+  };
+
+  var ClassName = {
+    DROPDOWN_MENU: 'dropdown-menu',
+    ACTIVE: 'active'
+  };
+
+  var Selector = {
+    DATA_SPY: '[data-spy="scroll"]',
+    ACTIVE: '.active',
+    LI_DROPDOWN: 'li.dropdown',
+    LI: 'li'
+  };
+
+  /**
+   * ------------------------------------------------------------------------
+   * Class Definition
+   * ------------------------------------------------------------------------
+   */
+
+  var ScrollSpy = (function () {
+    function ScrollSpy(element, config) {
+      _classCallCheck(this, ScrollSpy);
+
+      this._scrollElement = element.tagName === 'BODY' ? window : element;
+      this._config = $.extend({}, Defaults, config);
+      this._selector = '' + (this._config.target || '') + ' .nav li > a';
+      this._offsets = [];
+      this._targets = [];
+      this._activeTarget = null;
+      this._scrollHeight = 0;
+
+      $(this._scrollElement).on(Event.SCROLL, this._process.bind(this));
+
+      this.refresh();
+      this._process();
+    }
+
+    _createClass(ScrollSpy, [{
+      key: 'refresh',
+
+      // public
+
+      value: function refresh() {
+        var _this = this;
+
+        var offsetMethod = 'offset';
+        var offsetBase = 0;
+
+        if (this._scrollElement !== this._scrollElement.window) {
+          offsetMethod = 'position';
+          offsetBase = this._getScrollTop();
+        }
+
+        this._offsets = [];
+        this._targets = [];
+
+        this._scrollHeight = this._getScrollHeight();
+
+        var targets = $.makeArray($(this._selector));
+
+        targets.map(function (element) {
+          var target = undefined;
+          var targetSelector = Util.getSelectorFromElement(element);
+
+          if (targetSelector) {
+            target = $(targetSelector)[0];
+          }
+
+          if (target && (target.offsetWidth || target.offsetHeight)) {
+            // todo (fat): remove sketch reliance on jQuery position/offset
+            return [$(target)[offsetMethod]().top + offsetBase, targetSelector];
+          }
+        }).filter(function (item) {
+          return item;
+        }).sort(function (a, b) {
+          return a[0] - b[0];
+        }).forEach(function (item) {
+          _this._offsets.push(item[0]);
+          _this._targets.push(item[1]);
+        });
+      }
+    }, {
+      key: '_getScrollTop',
+
+      // private
+
+      value: function _getScrollTop() {
+        return this._scrollElement === window ? this._scrollElement.scrollY : this._scrollElement.scrollTop;
+      }
+    }, {
+      key: '_getScrollHeight',
+      value: function _getScrollHeight() {
+        return this._scrollElement.scrollHeight || Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
+      }
+    }, {
+      key: '_process',
+      value: function _process() {
+        var scrollTop = this._getScrollTop() + this._config.offset;
+        var scrollHeight = this._getScrollHeight();
+        var maxScroll = this._config.offset + scrollHeight - this._scrollElement.offsetHeight;
+
+        if (this._scrollHeight !== scrollHeight) {
+          this.refresh();
+        }
+
+        if (scrollTop >= maxScroll) {
+          var target = this._targets[this._targets.length - 1];
+
+          if (this._activeTarget !== target) {
+            this._activate(target);
+          }
+        }
+
+        if (this._activeTarget && scrollTop < this._offsets[0]) {
+          this._activeTarget = null;
+          this._clear();
+          return;
+        }
+
+        for (var i = this._offsets.length; i--;) {
+          var isActiveTarget = this._activeTarget !== this._targets[i] && scrollTop >= this._offsets[i] && (this._offsets[i + 1] === undefined || scrollTop < this._offsets[i + 1]);
+
+          if (isActiveTarget) {
+            this._activate(this._targets[i]);
+          }
+        }
+      }
+    }, {
+      key: '_activate',
+      value: function _activate(target) {
+        this._activeTarget = target;
+
+        this._clear();
+
+        var selector = '' + this._selector + '[data-target="' + target + '"],' + ('' + this._selector + '[href="' + target + '"]');
+
+        // todo (fat): getting all the raw li's up the tree is not great.
+        var parentListItems = $(selector).parents(Selector.LI);
+
+        for (var i = parentListItems.length; i--;) {
+          $(parentListItems[i]).addClass(ClassName.ACTIVE);
+
+          var itemParent = parentListItems[i].parentNode;
+
+          if (itemParent && $(itemParent).hasClass(ClassName.DROPDOWN_MENU)) {
+            var closestDropdown = $(itemParent).closest(Selector.LI_DROPDOWN)[0];
+            $(closestDropdown).addClass(ClassName.ACTIVE);
+          }
+        }
+
+        $(this._scrollElement).trigger(Event.ACTIVATE, {
+          relatedTarget: target
+        });
+      }
+    }, {
+      key: '_clear',
+      value: function _clear() {
+        var activeParents = $(this._selector).parentsUntil(this._config.target, Selector.ACTIVE);
+
+        for (var i = activeParents.length; i--;) {
+          $(activeParents[i]).removeClass(ClassName.ACTIVE);
+        }
+      }
+    }], [{
+      key: 'VERSION',
+
+      // getters
+
+      get: function () {
+        return VERSION;
+      }
+    }, {
+      key: 'Default',
+      get: function () {
+        return Default;
+      }
+    }, {
+      key: '_jQueryInterface',
+
+      // static
+
+      value: function _jQueryInterface(config) {
+        return this.each(function () {
+          var data = $(this).data(DATA_KEY);
+          var _config = typeof config === 'object' && config || null;
+
+          if (!data) {
+            data = new ScrollSpy(this, _config);
+            $(this).data(DATA_KEY, data);
+          }
+
+          if (typeof config === 'string') {
+            data[config]();
+          }
+        });
+      }
+    }]);
+
+    return ScrollSpy;
+  })();
+
+  /**
+   * ------------------------------------------------------------------------
+   * Data Api implementation
+   * ------------------------------------------------------------------------
+   */
+
+  $(window).on(Event.LOAD, function () {
+    var scrollSpys = $.makeArray($(Selector.DATA_SPY));
+
+    for (var i = scrollSpys.length; i--;) {
+      var $spy = $(scrollSpys[i]);
+      ScrollSpy._jQueryInterface.call($spy, $spy.data());
+    }
+  });
+
+  /**
+   * ------------------------------------------------------------------------
+   * jQuery
+   * ------------------------------------------------------------------------
+   */
+
+  $.fn[NAME] = ScrollSpy._jQueryInterface;
+  $.fn[NAME].Constructor = ScrollSpy;
+  $.fn[NAME].noConflict = function () {
+    $.fn[NAME] = JQUERY_NO_CONFLICT;
+    return ScrollSpy._jQueryInterface;
+  };
+
+  return ScrollSpy;
+})(jQuery);
+//# sourceMappingURL=scrollspy.js.map
\ No newline at end of file
diff --git a/js/dist/scrollspy.js.map b/js/dist/scrollspy.js.map
new file mode 100644
index 0000000000000000000000000000000000000000..ba686262fc673f2f9f97afea8a501eb0480ca028
Binary files /dev/null and b/js/dist/scrollspy.js.map differ
diff --git a/js/modal.js b/js/modal.js
deleted file mode 100644
index 0426561af69955886785c8d0e506eee08d9a68e4..0000000000000000000000000000000000000000
--- a/js/modal.js
+++ /dev/null
@@ -1,337 +0,0 @@
-/* ========================================================================
- * Bootstrap: modal.js v3.3.4
- * http://getbootstrap.com/javascript/#modals
- * ========================================================================
- * Copyright 2011-2015 Twitter, Inc.
- * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
- * ======================================================================== */
-
-
-+function ($) {
-  'use strict';
-
-  // MODAL CLASS DEFINITION
-  // ======================
-
-  var Modal = function (element, options) {
-    this.options             = options
-    this.$body               = $(document.body)
-    this.$element            = $(element)
-    this.$dialog             = this.$element.find('.modal-dialog')
-    this.$backdrop           = null
-    this.isShown             = null
-    this.originalBodyPad     = null
-    this.scrollbarWidth      = 0
-    this.ignoreBackdropClick = false
-
-    if (this.options.remote) {
-      this.$element
-        .find('.modal-content')
-        .load(this.options.remote, $.proxy(function () {
-          this.$element.trigger('loaded.bs.modal')
-        }, this))
-    }
-  }
-
-  Modal.VERSION  = '3.3.4'
-
-  Modal.TRANSITION_DURATION = 300
-  Modal.BACKDROP_TRANSITION_DURATION = 150
-
-  Modal.DEFAULTS = {
-    backdrop: true,
-    keyboard: true,
-    show: true
-  }
-
-  Modal.prototype.toggle = function (_relatedTarget) {
-    return this.isShown ? this.hide() : this.show(_relatedTarget)
-  }
-
-  Modal.prototype.show = function (_relatedTarget) {
-    var that = this
-    var e    = $.Event('show.bs.modal', { relatedTarget: _relatedTarget })
-
-    this.$element.trigger(e)
-
-    if (this.isShown || e.isDefaultPrevented()) return
-
-    this.isShown = true
-
-    this.checkScrollbar()
-    this.setScrollbar()
-    this.$body.addClass('modal-open')
-
-    this.escape()
-    this.resize()
-
-    this.$element.on('click.dismiss.bs.modal', '[data-dismiss="modal"]', $.proxy(this.hide, this))
-
-    this.$dialog.on('mousedown.dismiss.bs.modal', function () {
-      that.$element.one('mouseup.dismiss.bs.modal', function (e) {
-        if ($(e.target).is(that.$element)) that.ignoreBackdropClick = true
-      })
-    })
-
-    this.backdrop(function () {
-      var transition = $.support.transition && that.$element.hasClass('fade')
-
-      if (!that.$element.parent().length) {
-        that.$element.appendTo(that.$body) // don't move modals dom position
-      }
-
-      that.$element
-        .show()
-        .scrollTop(0)
-
-      that.adjustDialog()
-
-      if (transition) {
-        that.$element[0].offsetWidth // force reflow
-      }
-
-      that.$element.addClass('in')
-
-      that.enforceFocus()
-
-      var e = $.Event('shown.bs.modal', { relatedTarget: _relatedTarget })
-
-      transition ?
-        that.$dialog // wait for modal to slide in
-          .one('bsTransitionEnd', function () {
-            that.$element.trigger('focus').trigger(e)
-          })
-          .emulateTransitionEnd(Modal.TRANSITION_DURATION) :
-        that.$element.trigger('focus').trigger(e)
-    })
-  }
-
-  Modal.prototype.hide = function (e) {
-    if (e) e.preventDefault()
-
-    e = $.Event('hide.bs.modal')
-
-    this.$element.trigger(e)
-
-    if (!this.isShown || e.isDefaultPrevented()) return
-
-    this.isShown = false
-
-    this.escape()
-    this.resize()
-
-    $(document).off('focusin.bs.modal')
-
-    this.$element
-      .removeClass('in')
-      .off('click.dismiss.bs.modal')
-      .off('mouseup.dismiss.bs.modal')
-
-    this.$dialog.off('mousedown.dismiss.bs.modal')
-
-    $.support.transition && this.$element.hasClass('fade') ?
-      this.$element
-        .one('bsTransitionEnd', $.proxy(this.hideModal, this))
-        .emulateTransitionEnd(Modal.TRANSITION_DURATION) :
-      this.hideModal()
-  }
-
-  Modal.prototype.enforceFocus = function () {
-    $(document)
-      .off('focusin.bs.modal') // guard against infinite focus loop
-      .on('focusin.bs.modal', $.proxy(function (e) {
-        if (this.$element[0] !== e.target && !this.$element.has(e.target).length) {
-          this.$element.trigger('focus')
-        }
-      }, this))
-  }
-
-  Modal.prototype.escape = function () {
-    if (this.isShown && this.options.keyboard) {
-      this.$element.on('keydown.dismiss.bs.modal', $.proxy(function (e) {
-        e.which == 27 && this.hide()
-      }, this))
-    } else if (!this.isShown) {
-      this.$element.off('keydown.dismiss.bs.modal')
-    }
-  }
-
-  Modal.prototype.resize = function () {
-    if (this.isShown) {
-      $(window).on('resize.bs.modal', $.proxy(this.handleUpdate, this))
-    } else {
-      $(window).off('resize.bs.modal')
-    }
-  }
-
-  Modal.prototype.hideModal = function () {
-    var that = this
-    this.$element.hide()
-    this.backdrop(function () {
-      that.$body.removeClass('modal-open')
-      that.resetAdjustments()
-      that.resetScrollbar()
-      that.$element.trigger('hidden.bs.modal')
-    })
-  }
-
-  Modal.prototype.removeBackdrop = function () {
-    this.$backdrop && this.$backdrop.remove()
-    this.$backdrop = null
-  }
-
-  Modal.prototype.backdrop = function (callback) {
-    var that = this
-    var animate = this.$element.hasClass('fade') ? 'fade' : ''
-
-    if (this.isShown && this.options.backdrop) {
-      var doAnimate = $.support.transition && animate
-
-      this.$backdrop = $(document.createElement('div'))
-        .addClass('modal-backdrop ' + animate)
-        .appendTo(this.$body)
-
-      this.$element.on('click.dismiss.bs.modal', $.proxy(function (e) {
-        if (this.ignoreBackdropClick) {
-          this.ignoreBackdropClick = false
-          return
-        }
-        if (e.target !== e.currentTarget) return
-        this.options.backdrop == 'static'
-          ? this.$element[0].focus()
-          : this.hide()
-      }, this))
-
-      if (doAnimate) this.$backdrop[0].offsetWidth // force reflow
-
-      this.$backdrop.addClass('in')
-
-      if (!callback) return
-
-      doAnimate ?
-        this.$backdrop
-          .one('bsTransitionEnd', callback)
-          .emulateTransitionEnd(Modal.BACKDROP_TRANSITION_DURATION) :
-        callback()
-
-    } else if (!this.isShown && this.$backdrop) {
-      this.$backdrop.removeClass('in')
-
-      var callbackRemove = function () {
-        that.removeBackdrop()
-        callback && callback()
-      }
-      $.support.transition && this.$element.hasClass('fade') ?
-        this.$backdrop
-          .one('bsTransitionEnd', callbackRemove)
-          .emulateTransitionEnd(Modal.BACKDROP_TRANSITION_DURATION) :
-        callbackRemove()
-
-    } else if (callback) {
-      callback()
-    }
-  }
-
-  // these following methods are used to handle overflowing modals
-
-  Modal.prototype.handleUpdate = function () {
-    this.adjustDialog()
-  }
-
-  Modal.prototype.adjustDialog = function () {
-    var modalIsOverflowing = this.$element[0].scrollHeight > document.documentElement.clientHeight
-
-    this.$element.css({
-      paddingLeft:  !this.bodyIsOverflowing && modalIsOverflowing ? this.scrollbarWidth : '',
-      paddingRight: this.bodyIsOverflowing && !modalIsOverflowing ? this.scrollbarWidth : ''
-    })
-  }
-
-  Modal.prototype.resetAdjustments = function () {
-    this.$element.css({
-      paddingLeft: '',
-      paddingRight: ''
-    })
-  }
-
-  Modal.prototype.checkScrollbar = function () {
-    var fullWindowWidth = window.innerWidth
-    if (!fullWindowWidth) { // workaround for missing window.innerWidth in IE8
-      var documentElementRect = document.documentElement.getBoundingClientRect()
-      fullWindowWidth = documentElementRect.right - Math.abs(documentElementRect.left)
-    }
-    this.bodyIsOverflowing = document.body.clientWidth < fullWindowWidth
-    this.scrollbarWidth = this.measureScrollbar()
-  }
-
-  Modal.prototype.setScrollbar = function () {
-    var bodyPad = parseInt((this.$body.css('padding-right') || 0), 10)
-    this.originalBodyPad = document.body.style.paddingRight || ''
-    if (this.bodyIsOverflowing) this.$body.css('padding-right', bodyPad + this.scrollbarWidth)
-  }
-
-  Modal.prototype.resetScrollbar = function () {
-    this.$body.css('padding-right', this.originalBodyPad)
-  }
-
-  Modal.prototype.measureScrollbar = function () { // thx walsh
-    var scrollDiv = document.createElement('div')
-    scrollDiv.className = 'modal-scrollbar-measure'
-    this.$body.append(scrollDiv)
-    var scrollbarWidth = scrollDiv.offsetWidth - scrollDiv.clientWidth
-    this.$body[0].removeChild(scrollDiv)
-    return scrollbarWidth
-  }
-
-
-  // MODAL PLUGIN DEFINITION
-  // =======================
-
-  function Plugin(option, _relatedTarget) {
-    return this.each(function () {
-      var $this   = $(this)
-      var data    = $this.data('bs.modal')
-      var options = $.extend({}, Modal.DEFAULTS, $this.data(), typeof option == 'object' && option)
-
-      if (!data) $this.data('bs.modal', (data = new Modal(this, options)))
-      if (typeof option == 'string') data[option](_relatedTarget)
-      else if (options.show) data.show(_relatedTarget)
-    })
-  }
-
-  var old = $.fn.modal
-
-  $.fn.modal             = Plugin
-  $.fn.modal.Constructor = Modal
-
-
-  // MODAL NO CONFLICT
-  // =================
-
-  $.fn.modal.noConflict = function () {
-    $.fn.modal = old
-    return this
-  }
-
-
-  // MODAL DATA-API
-  // ==============
-
-  $(document).on('click.bs.modal.data-api', '[data-toggle="modal"]', function (e) {
-    var $this   = $(this)
-    var href    = $this.attr('href')
-    var $target = $($this.attr('data-target') || (href && href.replace(/.*(?=#[^\s]+$)/, ''))) // strip for ie7
-    var option  = $target.data('bs.modal') ? 'toggle' : $.extend({ remote: !/#/.test(href) && href }, $target.data(), $this.data())
-
-    if ($this.is('a')) e.preventDefault()
-
-    $target.one('show.bs.modal', function (showEvent) {
-      if (showEvent.isDefaultPrevented()) return // only register focus restorer if modal will actually get shown
-      $target.one('hidden.bs.modal', function () {
-        $this.is(':visible') && $this.trigger('focus')
-      })
-    })
-    Plugin.call($target, option, this)
-  })
-
-}(jQuery);
diff --git a/js/src/scrollspy.js b/js/src/scrollspy.js
new file mode 100644
index 0000000000000000000000000000000000000000..985da708d02be8172be39e889fe278a4e1fb897c
--- /dev/null
+++ b/js/src/scrollspy.js
@@ -0,0 +1,275 @@
+import Util from './util'
+
+
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v4.0.0): scrollspy.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+const ScrollSpy = (($) => {
+
+
+  /**
+   * ------------------------------------------------------------------------
+   * Constants
+   * ------------------------------------------------------------------------
+   */
+
+  const NAME                = 'scrollspy'
+  const VERSION             = '4.0.0'
+  const DATA_KEY            = 'bs.scrollspy'
+  const JQUERY_NO_CONFLICT  = $.fn[NAME]
+  const TRANSITION_DURATION = 150
+
+  const Defaults = {
+    offset : 10
+  }
+
+  const Event = {
+    ACTIVATE : 'activate.bs.scrollspy',
+    SCROLL   : 'scroll.bs.scrollspy',
+    LOAD     : 'load.bs.scrollspy.data-api'
+  }
+
+  const ClassName = {
+    DROPDOWN_MENU : 'dropdown-menu',
+    ACTIVE        : 'active'
+  }
+
+  const Selector = {
+    DATA_SPY    : '[data-spy="scroll"]',
+    ACTIVE      : '.active',
+    LI_DROPDOWN : 'li.dropdown',
+    LI          : 'li'
+  }
+
+
+  /**
+   * ------------------------------------------------------------------------
+   * Class Definition
+   * ------------------------------------------------------------------------
+   */
+
+  class ScrollSpy {
+
+    constructor(element, config) {
+      this._scrollElement = element.tagName === 'BODY' ? window : element
+      this._config        = $.extend({}, Defaults, config)
+      this._selector      = `${this._config.target || ''} .nav li > a`
+      this._offsets       = []
+      this._targets       = []
+      this._activeTarget  = null
+      this._scrollHeight  = 0
+
+      $(this._scrollElement).on(Event.SCROLL, this._process.bind(this))
+
+      this.refresh()
+      this._process()
+    }
+
+
+    // getters
+
+    static get VERSION() {
+      return VERSION
+    }
+
+    static get Default() {
+      return Default
+    }
+
+
+    // public
+
+    refresh() {
+      let offsetMethod = 'offset'
+      let offsetBase   = 0
+
+      if (this._scrollElement !== this._scrollElement.window) {
+        offsetMethod = 'position'
+        offsetBase   = this._getScrollTop()
+      }
+
+      this._offsets = []
+      this._targets = []
+
+      this._scrollHeight = this._getScrollHeight()
+
+      let targets = $.makeArray($(this._selector))
+
+      targets
+        .map((element) => {
+          let target
+          let targetSelector = Util.getSelectorFromElement(element)
+
+          if (targetSelector) {
+            target = $(targetSelector)[0]
+          }
+
+          if (target && (target.offsetWidth || target.offsetHeight)) {
+            // todo (fat): remove sketch reliance on jQuery position/offset
+            return [
+              $(target)[offsetMethod]().top + offsetBase,
+              targetSelector
+            ]
+          }
+        })
+        .filter((item)  => item)
+        .sort((a, b)    => a[0] - b[0])
+        .forEach((item) => {
+          this._offsets.push(item[0])
+          this._targets.push(item[1])
+        })
+    }
+
+
+    // private
+
+    _getScrollTop() {
+      return this._scrollElement === window ?
+          this._scrollElement.scrollY : this._scrollElement.scrollTop
+    }
+
+    _getScrollHeight() {
+      return this._scrollElement.scrollHeight || Math.max(
+        document.body.scrollHeight,
+        document.documentElement.scrollHeight
+      )
+    }
+
+    _process() {
+      let scrollTop    = this._getScrollTop() + this._config.offset
+      let scrollHeight = this._getScrollHeight()
+      let maxScroll    = this._config.offset
+        + scrollHeight
+        - this._scrollElement.offsetHeight
+
+      if (this._scrollHeight !== scrollHeight) {
+        this.refresh()
+      }
+
+      if (scrollTop >= maxScroll) {
+        let target = this._targets[this._targets.length - 1]
+
+        if (this._activeTarget !== target) {
+          this._activate(target)
+        }
+      }
+
+      if (this._activeTarget && scrollTop < this._offsets[0]) {
+        this._activeTarget = null
+        this._clear()
+        return
+      }
+
+      for (let i = this._offsets.length; i--;) {
+        let isActiveTarget = this._activeTarget !== this._targets[i]
+            && scrollTop >= this._offsets[i]
+            && (this._offsets[i + 1] === undefined ||
+                scrollTop < this._offsets[i + 1])
+
+        if (isActiveTarget) {
+          this._activate(this._targets[i])
+        }
+      }
+    }
+
+    _activate(target) {
+      this._activeTarget = target
+
+      this._clear()
+
+      let selector =
+        `${this._selector}[data-target="${target}"],` +
+        `${this._selector}[href="${target}"]`
+
+      // todo (fat): getting all the raw li's up the tree is not great.
+      let parentListItems = $(selector).parents(Selector.LI)
+
+      for (let i = parentListItems.length; i--;) {
+        $(parentListItems[i]).addClass(ClassName.ACTIVE)
+
+        let itemParent = parentListItems[i].parentNode
+
+        if (itemParent && $(itemParent).hasClass(ClassName.DROPDOWN_MENU)) {
+          let closestDropdown = $(itemParent)
+            .closest(Selector.LI_DROPDOWN)[0]
+          $(closestDropdown).addClass(ClassName.ACTIVE)
+        }
+      }
+
+      $(this._scrollElement).trigger(Event.ACTIVATE, {
+        relatedTarget: target
+      })
+    }
+
+    _clear() {
+      let activeParents = $(this._selector).parentsUntil(
+        this._config.target,
+        Selector.ACTIVE
+      )
+
+      for (let i = activeParents.length; i--;) {
+        $(activeParents[i]).removeClass(ClassName.ACTIVE)
+      }
+    }
+
+
+    // static
+
+    static _jQueryInterface(config) {
+      return this.each(function () {
+        let data    = $(this).data(DATA_KEY)
+        let _config = typeof config === 'object' && config || null
+
+        if (!data) {
+          data = new ScrollSpy(this, _config)
+          $(this).data(DATA_KEY, data)
+        }
+
+        if (typeof config === 'string') {
+          data[config]()
+        }
+      })
+    }
+
+
+  }
+
+
+  /**
+   * ------------------------------------------------------------------------
+   * Data Api implementation
+   * ------------------------------------------------------------------------
+   */
+
+  $(window).on(Event.LOAD, function () {
+    let scrollSpys = $.makeArray($(Selector.DATA_SPY))
+
+    for (let i = scrollSpys.length; i--;) {
+      let $spy = $(scrollSpys[i])
+      ScrollSpy._jQueryInterface.call($spy, $spy.data())
+    }
+  })
+
+
+  /**
+   * ------------------------------------------------------------------------
+   * jQuery
+   * ------------------------------------------------------------------------
+   */
+
+  $.fn[NAME]             = ScrollSpy._jQueryInterface
+  $.fn[NAME].Constructor = ScrollSpy
+  $.fn[NAME].noConflict  = function () {
+    $.fn[NAME] = JQUERY_NO_CONFLICT
+    return ScrollSpy._jQueryInterface
+  }
+
+  return ScrollSpy
+
+})(jQuery)
+
+export default ScrollSpy
diff --git a/js/tests/index.html b/js/tests/index.html
index 65c75acfee4983b5ddb24d25fb5782cab1ee036c..0262eec2a98fb6fd5f8854f809d61dee3216b978 100644
--- a/js/tests/index.html
+++ b/js/tests/index.html
@@ -137,9 +137,9 @@
     <script src="../../js/dist/collapse.js"></script>
     <script src="../../js/dist/dropdown.js"></script>
     <script src="../../js/dist/modal.js"></script>
+    <script src="../../js/dist/scrollspy.js"></script>
 
     <!-- Old Plugin sources -->
-    <script src="../../js/scrollspy.js"></script>
     <script src="../../js/tab.js"></script>
     <script src="../../js/tooltip.js"></script>
     <script src="../../js/popover.js"></script>
diff --git a/js/tests/visual/scrollspy.html b/js/tests/visual/scrollspy.html
index 5952b7e593517f52865a6663b058eb1a89768aeb..9958f90c08c7cd496fef2041894f9c151ae36945 100644
--- a/js/tests/visual/scrollspy.html
+++ b/js/tests/visual/scrollspy.html
@@ -24,21 +24,12 @@
 
   <nav class="navbar navbar-default navbar-fixed-top" role="navigation">
     <div class="container-fluid">
-      <div class="navbar-header">
-        <button class="navbar-toggle collapsed" type="button" data-toggle="collapse" data-target=".js-navbar-scrollspy">
-          <span class="sr-only">Toggle navigation</span>
-          <span class="icon-bar"></span>
-          <span class="icon-bar"></span>
-          <span class="icon-bar"></span>
-        </button>
-        <a class="navbar-brand" href="#">Project Name</a>
-      </div>
-      <div class="collapse navbar-collapse js-navbar-scrollspy">
+      <div class="js-navbar-scrollspy">
         <ul class="nav navbar-nav">
-          <li class=""><a href="#fat">@fat</a></li>
-          <li class=""><a href="#mdo">@mdo</a></li>
-          <li class="dropdown">
-            <a href="#" class="dropdown-toggle" data-toggle="dropdown">Dropdown <b class="caret"></b></a>
+          <li class="nav-item active"><a class="nav-link" href="#fat">@fat</a></li>
+          <li class="nav-item"><a class="nav-link" href="#mdo">@mdo</a></li>
+          <li class="dropdown nav-item">
+            <a href="#" class="dropdown-toggle nav-link" data-toggle="dropdown">Dropdown <b class="caret"></b></a>
             <ul class="dropdown-menu" role="menu">
               <li class=""><a href="#one" tabindex="-1">one</a></li>
               <li><a href="#two" tabindex="-1">two</a></li>
@@ -100,10 +91,10 @@
 
 <!-- JavaScript Includes -->
 <script src="../vendor/jquery.min.js"></script>
-<script src="../../transition.js"></script>
-<script src="../../scrollspy.js"></script>
-<script src="../../dropdown.js"></script>
-<script src="../../collapse.js"></script>
+<script src="../../dist/util.js"></script>
+<script src="../../dist/scrollspy.js"></script>
+<script src="../../dist/dropdown.js"></script>
+<script src="../../dist/collapse.js"></script>
 
 </body>
 </html>