An Unexpected Coder

opera singer turned web developer

Slide Slide Slippity Slide

How to Create a Slide Presentation with JavaScript

Recently a client needed a website that could be used both as a presentation tool (like a Keynote or PowerPoint presentation) and as a stand-alone website to be sent to possible investors to scroll through.

There are a lot of great libraries out there that help you mimic the functionality of a Keynote presentation. I took a look at flowtime, reveal.js, and impress.js. But, these libraries are big and seemed like overkill for what I needed to do. Also, I really wanted to see if I could build this on my own.

(If you want to jump ahead to the finished product, here’s an example of what I built along with the code on github.)

First I made my panels. I broke up the site into sections (literally — using <section></section> tags) and made each section height = 100vh. Actually, I had to make room for my fixed nav up top, so it was really height = calc(100vh - 102). Now that I had things looking the way they should, I needed to make them act the way they should.

I soon realized that my biggest challenge was to designate which section was the current panel so we could find the previous and next panels. I found this stackoverflow answer that seemed like it could be a solution. It used the jquery-visible plugin to tell if a section was in view and then found next and previous based on that. However, after the better part of a day going down that path, I realized it wouldn’t work for my needs. Before giving up, I asked another engineer if he had any idea how I could make this work (shout out, Kevin!). He said, “Why don’t you try using waypoints?” YES! Why didn’t I think of that?

Waypoints is a great library that lets you trigger a function on scroll. I could make each section have its own waypoint where it gets the class ‘currentPanel’ as soon as it’s almost to the top of the screen.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
getCurrentPanel: function() {
  var navHeight = $('header').outerHeight() - 1;

  $('section').each(function(){
    var index = $(this).index('section'),
        currentSection = $('section').get(index),
        nextSection = $(currentSection).next(),
        prevSection = $(currentSection).prev();
        currentPanelClass = 'current-panel';

    var waypointsDown = new Waypoint({
      element: currentSection,
      handler: function(direction) {
        if (direction === 'down') {
          $(currentSection).addClass(currentPanelClass);
          if (prevSection.length > 0) {
            prevSection.removeClass(currentPanelClass);
          }
        }
      },
      offset: 200
    });
    var waypointsUp = new Waypoint({
      element: currentSection,
      handler: function(direction) {
        if (direction === 'up') {
          $(currentSection).addClass(currentPanelClass);
          if ($(currentSection).length > 0) {
            nextSection.removeClass(currentPanelClass);
          }
        }
      },
      offset: navHeight
    });
  });
}

In that function, I’m looping through all the sections and assigning a waypoint to each based on its index. As you’re scrolling down, a section will get the class ‘currentPanel’ when it’s 200px from the top. On the way back up, a section would get that class as soon as it hits the nav. I’m also making sure that as the ‘currentPanel’ class is added to one section, it is removed from the section that just had it.

Now that I could find the current panel, I needed to designate next and previous panels and animate the scroll.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
findNext: function() {
  var that = this,
      currentPanel = $('.current-panel'),
      nextPanel = currentPanel.next('section');
  if (nextPanel.length > 0) {
    that.scrollToElement(nextPanel);
  }
},

findPrev: function() {
  var that = this,
      currentPanel = $('.current-panel'),
      prevPanel = currentPanel.prev('section');
  if (prevPanel.length > 0) {
    that.scrollToElement(prevPanel);
  }
},

scrollToElement: function(panel) {
  var navHeight = $('header').outerHeight();
  $('html, body').animate({
    scrollTop: panel.offset().top - navHeight
  }, 200);
}

Now I needed that scroll to happen in response to a presentation clicker. We had this one in the office, so I connected it to my computer and console logged the keyCodes to find which it simulated. It was using 33 and 34 (page up and page down). I also added a few more keyCodes in case the client forgot his clicker and needed to use the keyboard to move through the site.

1
2
3
4
5
6
7
8
9
10
11
12
13
slider: function(e) {
  var that = this;
  $(document).on('keydown', function(e){
    var $currentPanel = $('.currentPanel');
    if (e.keyCode === 40 || e.keyCode === 32 || e.keyCode === 13  || e.keyCode === 34 || e.keyCode === 39) {
      e.preventDefault();
      that.findNext();
    } else if (e.keyCode === 38 || e.keyCode === 33 || e.keyCode === 37) {
      e.preventDefault();
      that.findPrev();
    }
  });
}

Thanks to my ‘getCurrentPanel’ function, I knew that the panel with the class ‘currentPanel’ was my current panel, and could fire my ‘findNext’ or ‘findPrev’ functions based on whether the keyCodes called for up or down movement. Yay!

On to what I call ‘fragmented’ panels. By this I mean sections that call for a halt in the movement up and down and instead move through the content inside the panel itself. We’re also looking out for a fragment-subnav if it has one. All the fragmented examples in my demo site have subnavs. They give a great visual cue that there are other fragments and lets the user easily navigate them. But a subnav is not required.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
fragmentedPanel: function(movement) {
  var that = this,
      currentFragmentedPanel = $('.current-panel.fragmented'),
      currentFragmentedParts = currentFragmentedPanel.find('.fragmented-part.active');
  currentFragmentedParts.each(function(){
    var fragmentIndex = $(this).index('.current-panel .fragmented-part'),
        fragment = $('.current-panel .fragmented-part').get(fragmentIndex),
        nextFragment = $(fragment).next(),
        prevFragment = $(fragment).prev(),
        checkForLast = nextFragment.next(),
        checkForFirst = fragmentIndex - 2,
        hasSubnav = currentFragmentedPanel.find('.fragment-subnav').length;
    if (hasSubnav) {
      var activeSubnav = $('.current-panel .fragment-subnav.active'),
          nextSubnav = $('.current-panel .fragment-subnav').get(fragmentIndex + 1),
          prevSubnav = $('.current-panel .fragment-subnav').get(fragmentIndex - 1);
    }
    if (movement === 'down' && nextFragment.length > 0) {
      if (checkForLast.length > 0) {
        currentFragmentedParts.removeClass('active');
        nextFragment.addClass('active');
        currentFragmentedPanel.removeClass('first-fragment');
        currentFragmentedPanel.removeClass('last-fragment');
        if (hasSubnav) {
          activeSubnav.removeClass('active');
          $(nextSubnav).addClass('active');
        }
      } else {
        currentFragmentedParts.removeClass('active');
        nextFragment.addClass('active');
        currentFragmentedPanel.removeClass('first-fragment');
        currentFragmentedPanel.addClass('last-fragment');
        if (hasSubnav) {
          activeSubnav.removeClass('active');
          $(nextSubnav).addClass('active');
        }
      }
    } else if (movement === 'up' && prevFragment.length > 0) {
      if (checkForFirst >= 0) {
        currentFragmentedParts.removeClass('active');
        prevFragment.addClass('active');
        currentFragmentedPanel.removeClass('first-fragment').removeClass('last-fragment');
        if (hasSubnav) {
          activeSubnav.removeClass('active');
          $(prevSubnav).addClass('active');
        }
      } else {
        currentFragmentedParts.removeClass('active');
        prevFragment.addClass('active');
        currentFragmentedPanel.removeClass('last-fragment').addClass('first-fragment');
        if (hasSubnav) {
          activeSubnav.removeClass('active');
          $(prevSubnav).addClass('active');
        }
      }
    }
  });
}

For the code in your fragmented sections to work, you need to set up your HTML with certain classes. The ‘section’ will need to get the classes ‘fragmented’ and ‘first-fragment’. The fragments within your fragmented section need to get the class ‘fragmented-part’. The subnav items should get the class ‘fragment-subnav’. And the first fragment item and first subnav item should always get the ‘active’ class. Here’s an example of how to set up the HTML:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<section class="fragmented first-fragment">
  <!-- this is a slide with fragments -->
  <div class="fragmented-part active"></div>
  <div class="fragmented-part"></div>
  <div class="fragmented-part"></div>
</section>

<section class="fragmented first-fragment">
  <!-- this is a slide with fragments and a subnav-->
  <div class="fragment-subnav active"></div>
  <div class="fragment-subnav"></div>
  <div class="fragment-subnav"></div>

  <div class="fragmented-part active"></div>
  <div class="fragmented-part"></div>
  <div class="fragmented-part"></div>
</section>

A big thing in the fragmentedPanel function is to know when you’re reaching the end of the fragmented-parts so you can move on to the next slide. That’s why I have the ‘checkForLast’ and ‘checkForFirst’ variables. With every movement, I’m checking to see if an element exists in the next slot.

Great, now we need to update our slider function to halt movement if we see a ‘fragmented’ section.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
slider: function(e) {
  var that = this;
  $(document).on('keydown', function(e){
    var $currentPanel = $('.currentPanel');
    if (e.keyCode === 40 || e.keyCode === 32 || e.keyCode === 13  || e.keyCode === 34 || e.keyCode === 39) {
      e.preventDefault();
      if (currentPanel.hasClass('fragmented') && !(currentPanel.hasClass('last-fragment'))) {
        that.fragmentedPanel('down');
      } else {
        that.findNext();
      }
    } else if (e.keyCode === 38 || e.keyCode === 33 || e.keyCode === 37) {
      e.preventDefault();
      if ( currentPanel.hasClass('fragmented') && !(currentPanel.hasClass('first-fragment')) ) {
        that.fragmentedPanel('up');
      } else {
        that.findPrev();
      }
    }
  });
}

That’s pretty much it. I hope this makes some kind of sense to you. You can take a look at the full code here and a demo here.

And in honor of this blog post’s title. I leave you with a little Coolio circa 1994.