Single Page Bulma template with Smooth Scroll and Scroll Spy JavaScript

Posted

I was building a single page website using Bulma, and I wanted to achieve effects similar to what I was familiar with using Bootstrap’s Creative one page theme. As you know, Bulma (mostly) does not provide any JavaScript code, so I had to code the Smooth Scrolling and Scroll Spy features I missed. I intentionally avoided jQuery! So I present my solution using plain-old JavaScript.

Single Page Website Template

First off, the HTML template. I prefer always using unique element IDs for speed and specificity, and the key points when creating the navbar are:

  • Create a Navigation Bar (“navbar”) with the navbar class - only one navbar please,
  • Set a unique ID to the navbar menu (a.k.a burger) (navbar-burger class), and set a data-target to point to the ID of the menu items defined in the next step,
  • Set a unique ID to the container for the navbar menu items (navbar-menu class),
  • Create multiple menu items (navbar-item class) each with a link to specific section in the same page (href="#anchor") and a data-target to same unique (anchor) ID of the section.

The rest of the template, section elements a, b and c are just for demo purposes - clicking the menu item will link to these sections.

<!doctype html>
<html>
<head><meta http-equiv="Content-Type" content="text/html; charset=utf-8">

    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Bulma JavaScript tests</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.8.0/css/bulma.min.css">
</head>
<body>
    <nav class="navbar is-link is-fixed-top" role="navigation" aria-label="main navigation">
        <div class="navbar-brand">
            <a class="navbar-item" href=""></a>  
            <a id="navbar-burger" role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="menubar">
                <span aria-hidden="true"></span>
                <span aria-hidden="true"></span>
                <span aria-hidden="true"></span>
            </a>
        </div>
        <div id="menubar" class="navbar-menu">
            <div class="navbar-end">
                <a class="navbar-item" href="#a" data-target="a">Section A</a>
                <a class="navbar-item" href="#b" data-target="b">Section B</a>
                <a class="navbar-item" href="#c" data-target="c">Section C</a>
            </div>
        </div>
    </nav>
    <section id="a" class="section">
        <div class="notification is-success" style="height:30em">Section A</div>
    </section>
    <section id="b" class="section">
        <div class="notification is-warning" style="height:10em">Section B</div>
    </section>
    <section id="c" class="section">
        <div class="notification is-danger" style="height:50em">Section C</div>
    </section>
</body>
</html>

Navbar Burger

Some code to initialize the burger - which only appears on mobile and tablet devices - so that on click:

  • the burger icon is toggled to a cross, and
  • the child menu items are expanded / collapsed:
const burger = document.getElementById('navbar-burger');
burger.addEventListener('click', () => {
    burger.classList.toggle('is-active');
    document.getElementById(burger.dataset.target).classList.toggle('is-active');
});

I do it in a more simplified manner than Bulma’s sample JavaScript implementation by assuming exactly one navbar with a unique ID.

Scroll Spy

What I refer to as “scroll spy” simply highlights the navbar menu item based on the section that is currently visible after the user scrolls.

First some setup:

  • Keep a track of all the menu items and the actual section elements they target,
  • A function to check if each section is visible - in this case I also take into consideration the fixed navbar. Code block for isVisible() adapted from davidtheclark, who in turn credits Dan.
  • And another function to go through each menu item, checking if the corresponding section is visible, and if so adding the is-active class to the menu item.

Now the real work, listening for the scroll event. Here I implement Mozilla’s scroll event throttling, using requestAnimationFrame() to wait till the nest animation before repainting.

const navItems = document.getElementById('menubar').firstElementChild.children,
    navSections = new Array(navItems.length);
for (i = 0; i < navItems.length; i++)
    navSections[i] = document.getElementById(navItems[i].dataset.target);

const menuBarHeight = document.getElementById('menubar').offsetHeight;
function isVisible(ele) {
    const r = ele.getBoundingClientRect();
    const h = (window.innerHeight || document.documentElement.clientHeight);
    const w = (window.innerWidth || document.documentElement.clientWidth);
    return (r.top <= h) && 
        (r.top + r.height - menuBarHeight >= 0) && 
        (r.left <= h) && 
        (r.left + r.width >= 0);
}
function activateIfVisible() {
    for (b = true, i = 0; i < navItems.length; i++) {
        if (b && isVisible(navSections[i])) {
            navItems[i].classList.add('is-active');
            b = false;
        } else 
            navItems[i].classList.remove('is-active');
    }
}
var isTicking = null;
window.addEventListener('scroll', () => {
    if (!isTicking) {
        window.requestAnimationFrame(() => {
            activateIfVisible();
            isTicking = false;
        });
        isTicking = true;
    }
}, false);

I’ve also tested out the setTimeout() alternative instead - depending on the timeout (80ms here), this seems to take more CPU and is obviously not as smooth. Honestly though, I don’t know how to measure performance so don’t know which is better/faster. Anyway:

var isScrolling = null;
window.addEventListener('scroll', () => {
    window.clearTimeout(isScrolling);
    isScrolling = setTimeout(() => {
        activateIfVisible();
    }, 80);
}, false);

Smooth Scroll

Finally, for smooth scrolling (with thanks to the ideas on StackOverflow):

  • Capture the click on the menu item,
  • Prevent the default link (i.e. don’t follow href="#anchor") - unless what you want is to record the same-page links visited in the browser (back button) history,
  • And then programmatically scroll using scroll() with the behaviour: 'smooth'ScrollToOptions.
for (item of navItems) {
    item.addEventListener('click', e => {
        e.preventDefault();
        window.scroll({ 
            behavior: 'smooth', 
            left: 0, 
            top: document.getElementById(e.target.dataset.target).getBoundingClientRect().top + 
                window.scrollY 
        });
    });
}

Does not work on some browsers, most notably on IE and all versions to date of Safari on macOS and iOS!

Bonus: Hero Background Image

What’s described here is not in the sample template above.

Bulma’s hero class is used to create a giant hero banner, filling the width of the disply and optionally filling the entire height too with is-fullheight.

<section id="hero" class="hero is-success is-fullheight">
  <div class="hero-body">
    <div class="container">
      <h1 class="title">Hello world!</h1>
    </div>
  </div>
</section>

To set a background image via CSS, I used:

#hero {
    background: url(image.jpg) center center no-repeat fixed; 
}

I wanted a fixed background that does not scroll (see Mozilla Developer Network background-attachment documentation:

fixed: The background is fixed relative to the viewport. Even if an element has a scrolling mechanism, the background doesn't move with the element.

Well, Safari on iOS does not support fixed! So, the workaround is to revert back to the default on mobile, e.g.

@media only screen and (max-width: 414px) {
    #hero { background-attachment:scroll; }
}

Apart from that, I also learnt that instead of center center, one can set the background-position-x and background-position-y in combinations of top|center|bottom and length|percentage offsets, e.g. background-position-y: bottom 3px; background-position-y: bottom 10%;. Cool.

length: The offset of the given background image's horizontal edge from the corresponding background position layer's top horizontal edge. (Some browsers allow assigning the bottom edge for offset).
percentage: The offset of the given background image's vertical position relative to the container. A value of 0% means that the top edge of the background image is aligned with the top edge of the container, and a value of 100% means that the bottom edge of the background image is aligned with the bottom edge of the container, thus a value of 50% vertically centers the background image.

And, I think background-size: cover is great too, again quoting from MDN:

cover: Scales the image as large as possible without stretching the image. If the proportions of the image differ from the element, it is cropped either vertically or horizontally so that no empty space remains.

Conclusion

Try it all in action here

So that’s how I built a one page website using Bulma while retaining a few features similar to a popular Bootstrap template. Hope it helps!