Using AngularJS to recreate Bootstrap's ScrollSpy

Written by Alexander Hill

22. August 2013

Twitter Bootstrap’s ScrollSpy and Affix plugins let a page update its navigation bar based on where the page is scrolled. I’ve written a simple version of this plugin using AngularJS directives, which works for both horizontal and vertial navigation bars.

ScrollSpy in Angular JS

How it works

This method comprises of two directives - one on the container element (parent of both sidebar and content) and one on each of the sidebar navigation elements.

Here’s a HTML snippet that uses these directives:

<div class="row" scroll-spy>
    <div class="col-md-3 sidebar">
        <ul>
            <li spy="overview">Overview</li>
            <li spy="main">Main Content</li>
            <li spy="summary">Summary</li>
            <li spy="links">Other Links</li>
        </ul>
    </div>
    <div class="col-md-9 content">
        <h3 id="overview">Overview</h3>
        <!-- overview goes here -->
        <h3 id="main">Main Body</h3>
        <!-- main content goes here -->
        <h3 id="summary">Summary</h3>
        <!-- summary goes here -->
        <h3 id="links">Other Links</h3>
        <!-- other links go here -->
    </div>
</div>

The first directive, called scrollSpy is placed on the container row, whilst the second is on the sidebar li elements.

The scrollSpy directive holds an array of the ‘spies’ that represent different sections of the page. As the page is scrolled, it works out which section of the page is currently visible and highlights the relevant sidebar element.

Here’s the CoffeeScript for the first directive:

app.directive 'scrollSpy', ($window) ->
  restrict: 'A'
  controller: ($scope) ->
    $scope.spies = []
    # a spyObj has an id, a function to call when it's section is in view,
    # and a function to call when it's out of sight.
    # This is created in the second directive
    @addSpy = (spyObj) -> $scope.spies.push spyObj
  link: (scope, elem, attrs) ->
    spyElems = []
    scope.$watch 'spies', (spies) ->
      for spy in spies
        unless spyElems[spy.id]?
          spyElems[spy.id] = elem.find('#'+spy.id)

    $($window).scroll ->
      highlightSpy = null
      for spy in scope.spies
        spy.out()
        if (pos = spyElems[spy.id].offset().top) - $window.scrollY <= 0
          spy.pos = pos
          highlightSpy ?= spy
          if highlightSpy.pos < spy.pos
            highlightSpy = spy

      highlightSpy?.in()

(Note - an updated version of this that works when the contents of the page has been created dynamically is available here)

As you can see, this directive has a controller. This lets us access the addSpy method from other directives that require this one.

Looking at the link function, you’ll notice we watch for changes on the spies array and push an element with the spy’s id into a new array. This means we can access the section elements without having to search for them each time the page is scrolled. It is stored in a separate array for performance reasons, as DOM elements should never be placed on the scope.

In the scroll event handler, we look through every spy and work out which section is in view. A section is in view when the difference between the current scroll position and its top offset is the smallest negative number. Finally, the in function for the correct spy is called, if any section is in view.

This should make a little more sense after looking at the spy directive:

app.directive 'spy', ->
  restrict: "A"
  require: "^scrollSpy"
  link: (scope, elem, attrs, affix) ->
    affix.addSpy
      id: attrs.spy
      in: -> elem.addClass 'current',
      out: -> elem.removeClass 'current'

Using the require property of the directive definition object gives us access to the scrollSpy controller. The ^ in front of the name asks Angular to check for the directive on the current element and any parent elements, and passes the controller as the fourth parameter to the linking function. (N.B: You can also prefix ? to make the controller optional. No prefix will require the directive to be on the same element. Read more on the AngularJS directives page)

Finally, you’ll need some CSS to style the current class on the sidebar elements, as well as ensuring the sidebar is fixed to the top of the page as it scrolls.

The spy directive can also be extended to update the hash fragment by injecting the $location service and calling $location.hash(attrs.spy) in the in function - but don’t forget to call it from scope.$apply. Additionally, if you’re using $anchorScroll in your application controller, updating $location.hash() on clicking the element will jump the page to the correct section.

If you need a hand with any of the code here, or want to correct something I’ve written, feel free to tweet me @alxhill.

You might also be interested in my post on using Angular with CoffeeScript.