How to Build an Underwater-Style Navigation Using PixiJS

A tutorial on how to create a visually distinct and accessible WebGL menu that builds from any given HTML navigation.

This demo shows one way to make a navigation that is visually distinct, usable and accessible. Using the provided code, you can create all sorts of variations on this theme. I encourage you to try your hand at modifying it in interesting ways.

The inspiration for this demo comes from the Dribbble shot Holidays Menu by BestServedBold.

Attention: This tutorial assumes that you are proficient with JavaScript. A familiarity with WebGL and PixiJS is useful, but not required to step through this. If you would like to become more familiar with either fragment shaders or PixiJS, visit:

How it works: General Approach

In this demo we start with a simple HTML navigation:

<label class="main-nav-open nav-toggle" for="main-nav-toggle" tabindex="0">
  Menu
</label>
<input type="checkbox" id="main-nav-toggle" />
<nav class="main-nav">
  <ul class="main-nav__fallback">
    <li>
      <a href="/">Home</a>
    </li>
    <li>
      <a href="/about">About</a>
    </li>
    <li>
      <a href="/work">Our Work</a>
    </li>
    <li>
      <a href="/team">The Team</a>
    </li>
    <li>
      <a href="/contact">Contact Us</a>
    </li>
  </ul>
  <label class="main-nav__close nav-toggle" for="main-nav-toggle" tabindex="0" />
</nav>

With this basic setup we end up with a navigation that looks like this:
Step 1, the basic HTML navigation setup

You can view the demo for this step here: Demo of step 1

Given this, we will write some JavaScript that builds out the navigation elements, and builds out a PixiJS application.
At that point it looks something like this:

Step 2, the navigation converted to PixiJS

Once we have this fundamental functionality in place, we can add the really fun stuff: shaders.

We’ll be building out two different shaders (or, in PixiJS’s nomenclature: filters). One for the screen as a whole that distorts and blurs the navigation. The second is the shader that renders the buttons on hover or focus.

So, with our general approach in place, let’s dive in.

Building the navigation class

For this tutorial, I’m going to focus on the JavaScript. In the source you’ll find the fully commented JavaScript and shader code. I will not be covering the fundamentals of GLSL or fragment shader programming here, for that please see The Book of Shaders.

Initialisation

The very first thing we want to do is set up the basic initialisation functionality. In the main.js file you’ll find the following:

// Create the navigation based on the nav element
const nav = new Navigation(document.querySelector('.main-nav'));

// Load the web font and, once it's loaded, initialise the nav.
WebFont.load({
  google: {
    families: ['Abril Fatface']
  },
  active: () => {
    nav.init();
    nav.focusNavItemByIndex(0);
  }
});

The above code creates the Navigation instance, supplying it with the navigation HTML element – document.querySelector('.main-nav'). Then it initialises the font load for Abril Fatface and, once that’s loaded, initialises the navigation.

Navigation class structure

Following is the basic class structure for the Navigation class:

/**
 * This class provides encapsulates the navigation as a whole. It is provided the base
 * navigation element which it reads and recreates in the Pixi application
 *
 * @class Navigation
 * @author Liam Egan 
 * @version 1.0.0
 * @created Mar 20, 2019
 */
class Navigation {

  /**
   * The Navigation constructor saves the navigation element and binds all of the 
   * basic listener methods for the class.
   * 
   * The provided nav element should serve as both a container to the pixi canvas
   * as well as containing the links that will become the navigation. It's important
   * to understand that any elements within the navigation element that might appear
   * will be covered by the application canvas, so it should serve only as a 
   * container for the navigation links and the application canvas.
   *
   * @constructor
   * @param {HTMLElement} nav         The navigation container.
   */
  constructor(nav) { }
  
  /**
   * Initialises the navigation. Creates the navigation items, sets up the pixi 
   * application, and binds the various listeners.
   *
   * @public
   * @return null
   */
  init() { }
  
  /**
   * Initialises the Navigation item elements, initialising their canvas 
   * renditions, their pixi sprites and initialising their interactivity.
   *
   * @public
   * @return null
   */
  makeNavItems() { }
  
  /**
   * Public methods
   */

  /**
   * Initialises the Navigation item as a canvas element. This takes a string and renders it
   * to the canvas using fillText. 
   *
   * @public
   * @param {String} title      The text of the link element
   * @return {Canvas}           The canvas alement that contains the text rendition of the link
   */
  makeNavItem(title) { }
  
  /**
   * Initialises the PIXI application and appends it to the nav element
   *
   * @public
   * @return null
   */
  setupWebGLContext() { }
  
  /**
   * Given a numeric index, this calculates the position of the 
   * associated nav element within the application and simulates
   * a mouse move to that position.
   *
   * @public
   * @param {Number} index      The index of the navigation element to focus.
   * @return null
   */
  focusNavItemByIndex(index) { }
  
  /**
   * Removes all of the event listeners and any association of
   * the navigation object, preparing the instance for garbage
   * collection.
   * 
   * This method is unused in this demo, but exists here to 
   * provide somewhere for you to remove all remnents of the 
   * instance from memory, if and when you might need to.
   * 
   *
   * @public
   * @return null
   */
  deInit() { }

  /**
   * Redraws the background graphic and the container mask.
   *
   * @public
   * @return null
   */
  setupBackground() { }

  /**
   * Coerces the mouse position as a vector with units in the 0-1 range
   *
   * @public
   * @param {Array} mousepos_px      An array of the mouse's position on screen in pixels
   * @return {Array}
   */
  fixMousePos(mousepos_px) { }


  
  /**
   * Event callbacks
   */
  
  /**
   * Responds to the window resize event, resizing the stage and redrawing 
   * the background.
   *
   * @public
   * @param {Object} e     The event object
   * @return null
   */
  onResize(e) { }
  
  /**
   * Responds to the window pointer move event, updating the application's mouse
   * position.
   *
   * @public
   * @param {Object} e     The event object
   * @return null
   */
  onPointerMove(e) { }
  
  /**
   * Responds to the window pointer down event, creating a timeout that checks,
   * after a short period of time, whether the pointer is still down, after 
   * which it sets the dragging property to true.
   *
   * @public
   * @param {Object} e     The event object
   * @return null
   */
  onPointerDown(e) { }
  
  /**
   * Responds to the window pointer up event, sets pointer down to false and,
   * after a short time, sets dragging to false.
   *
   * @public
   * @param {Object} e     The event object
   * @return null
   */
  onPointerUp(e) { }


  
  /**
   * Getters and setters (properties)
   */

  /**
   * (getter/setter) The colour of the application background. This can take
   * a number or an RGB hex string in the format of '#FFFFFF'. It stores
   * the colour as a number
   *
   * @type {number/string}
   * @default 0xF9F9F9
   */
  set backgroundColour(value) { }
  get backgroundColour() { }

  /**
   * (getter/setter) The position of the mouse/pointer on screen. This 
   * updates the position of the navigation in response to the cursor
   * and fixes the mouse position before passing it to the screen
   * filter.
   *
   * @type {Array}
   * @default [0,0]
   */
  set mousepos(value) { }
  get mousepos() { }

  /**
   * (getter/setter) The amount of padding at the edge of the screen. This
   * is sort of an arbitrary value at the moment, so if you start to see 
   * tearing at the edge of the text, make this value a little higher
   *
   * @type {Number}
   * @default 100
   */
  set maskpadding(value) { }
  get maskpadding() { }
}

The Navigation constructor is given the nav HTML element and it initialises all of the basic properties of the class


  constructor(nav) {
    // Save the nav
    this.nav = nav;

    // Set up the basic object property requirements.
    this.initialised = false;   // Whether the navigation is already initialised
    this.navItems = [];         // This will contain the generic nav item objects
    this.app = null;            // The PIXI application
    this.container = null;      // The PIXI container element that will contain the nav elements
    this.screenFilter = null;   // The screen filter to be appliced to the container
    this.navWidth = null;       // The full width of the navigation
    this.background = null;     // The container for the background graphic
    this.pointerdown = false;   // Indicates whether the user's pointer is currently down on the page
    this.dragging = false;      // Indicates whether the nav is currently being dragged. This is here to allow for both the dragging of the nav and the tapping of elements.

    // Bind the listener methods to the class instance
    this.onPointerMove = this.onPointerMove.bind(this);
    this.onPointerDown = this.onPointerDown.bind(this);
    this.onPointerUp = this.onPointerUp.bind(this);
    this.onResize = this.onResize.bind(this);
  }

Building the navigation elements

Once the class is constructed and the fonts are loaded, we initialise the navigation.
The first part of building out the PixiJS application is to create the navigation items. PixiJS uses specialised objects for the display of various elements that, at their root, are images. So the first thing we need to do is initialise an array of all of the nav items that we want. Within our init method, we see the following:

// Find all of the anchors within the nav element and create generic object
    // holders for them. 
    const els = this.nav.querySelectorAll('a');
    els.forEach((el) => {
      this.navItems.push({
        rootElement:  el,             // The anchor element upon which this nav item is based
        title:        el.innerText,   // The text of the nav item
        element:      null,           // This will be a canvas representation of the nav item
        sprite:       null,           // The PIXI.Sprite element that will be appended to stage
        link:         el.href         // The link's href. This will be used when clicking on the button within the nav
      });
    });

This code loops through the anchor elements within the navigation and initialises the basic objects for use by the navigation class. Once this is done we can move onto actually creating the basic nav elements:

makeNavItem(title) {
    if(!this.initialised) return;

    const c = document.createElement('canvas');
    const ctx = c.getContext('2d');

    const font = 'Abril Fatface';
    const fontSize = 80;

    ctx.font = `${fontSize}px ${font}`; // This is here purely to run the measurements

    c.width = ctx.measureText(title).width + 50;
    c.height = fontSize*1.5;

    ctx.font = `${fontSize}px ${font}`;
    ctx.textAlign="center";
    ctx.textBaseline="bottom"; 
    ctx.fillStyle = "rgba(40,50,60,1)";
    ctx.fillText(title, c.width*.5, c.height-fontSize*.2);

    return c;
  }

The above code takes a title (provided by a loop in the makeNavItems method), it creates a canvas element, initialises the font, and writes out the text onto the canvas.

The reason we do this instead of writing the text directly into PixiJS is that font rendition in canvas is more predictable and reliable. Doing so also provides us with the opportunity to add other canvas styling such as text strokes, or shadows.

Initialising the PixiJS application

Following is the method uses to initialise the PixiJS application:

setupWebGLContext() {
    if(!this.initialised) return;

    // Create the pixi application, setting the background colour, width and
    // height and pixel resolution.
    this.app = new PIXI.Application({
      backgroundColor: this.backgroundColour,
      width: window.innerWidth,
      height: window.innerHeight,
      resolution: 2
    });
    // Ofsetting the stage to the middle of the page. I find it easier to 
    // position things to a point in the middle of the window, so I do this
    // but you might find it easier to position to the top left.
    this.app.stage.x = window.innerWidth * .5;
    this.app.stage.y = window.innerHeight * .5;

This initialises the application and rearranges the stage to the middle of the window.

// Create the container and apply the screen filter to it.
    this.container = new PIXI.Container();
    this.screenFilter = new ScreenFilter(2);
    this.app.stage.filters = [this.screenFilter];

This creates the object that contains the navigation itself and applies to it the screen filter.

// Measure what will be the full pixel width of the navigation 
    // Then loop through the nav elements and append them to the containter
    let ipos = 0;                                 // The tracked position for each element in the navigation
    this.navWidth = 0;                            // The full width of the navigation
    this.navItems.forEach((item) => {
      this.navWidth += item.sprite.width;
    });
    this.navItems.forEach((item) => {
      item.sprite.x = this.navWidth * -.5 + ipos; // Calculate the position of the nav element to the nav width
      ipos += item.sprite.width;                  // update the ipos
      this.container.addChild(item.sprite);       // Add the sprite to the container
    });

This code loops through the navigation items and calculates their positions inside the PixiJS application.

// Create the background graphic 
    this.background = new PIXI.Graphics();
    this.setupBackground();

    // Add the background and the container to the stage
    this.app.stage.addChild(this.background);
    this.app.stage.addChild(this.container);

Sets up the background and attaches it and the container to the stage.

// Set the various necessary attributes and class for the canvas 
    // elmenent and append it to the nav element.
    this.app.view.setAttribute('aria-hidden', 'true');    // This just hides the element from the document reader (for sight-impaired people)
    this.app.view.setAttribute('tab-index', '-1');        // This takes the canvas element out of tab order completely (tabbing will be handled programatically using the actual links)
    this.app.view.className = 'main-nav__canvas';         // Add the class name
    this.nav.appendChild(this.app.view);                  // Append the canvas to the nav element
  }

Finally we set some basic properties to the application’s canvas element and we attach it to the nav element that was provided to the class constructor.

Making it interactive

makeNavItems() {
    if(!this.initialised) return;

    // Loop through the navItems object
    this.navItems.forEach((navItem, i) => {
      // Make the nav element (the canvas rendition of the anchor) for this item.
      navItem.element = this.makeNavItem(navItem.title, navItem.link);

      // Create the PIXI sprite from the canvas
      navItem.sprite = PIXI.Sprite.from(navItem.element);

      // Turn the sprite into a button and initialise the various event listeners
      navItem.sprite.interactive = true;
      navItem.sprite.buttonMode = true;
      const filter = new HoverFilter();
      // This provides a callback for focus on the root element, providing us with
      // a way to cause navigation on tab.
      navItem.rootElement.addEventListener('focus', ()=> {
        this.focusNavItemByIndex(i);
        navItem.sprite.filters = [filter];
      });
      navItem.rootElement.addEventListener('blur', ()=> {
        navItem.sprite.filters = [];
      });
      // on pointer over, add the filter
      navItem.sprite.on('pointerover', (e)=> {
        navItem.sprite.filters = [filter];
      });
      // on pointer out remove the filter
      navItem.sprite.on('pointerout', (e)=> {
        navItem.sprite.filters = [];
      });
      // On pointer up, if we're not dragging the navigation, execute a click on
      // the root navigation element.
      navItem.sprite.on('pointerup', (e)=> {
        if(this.dragging) return;
        navItem.rootElement.click();
      });
    });
  }

This method loops through the navItems array creating the nav item canvas element, initialising the PixiJS Sprite element using this canvas, then attaching listeners to various interactions.

Note that in order for a PixiJS sprite to be able to react to mouse events in this way it needs to have its interactuve and buttonMode properties set to true

The reasons and functions of the listeners here is as follows:

rootElement:focus
This listens to the focus event of the anchor element that defines this button. It then tells the application to move to this nav item and add the filter we use for mouse hover.
rootElement:blur
Triggered when focus leaves the element. This simply removes the filter
navItem.pointerover
Adds the hover filter
navItem.pointerout
Removes the hover filter
navItem.pointerup
This listens to the pointer up event on the nav item and, if the user isn’t dragging the nav (this check is here for touch devices), then a click event is triggered on the root element.

In addition to the above, we also add stage-level listeners that provide the overall movement functionality to the navigation. The reasons and functions of the listeners here is as follows:

onPointerMove
This method updates the mouse position when the pointer moves and sends it to the application.
onPointerDown
This picks up when the pointer has been pressed and starts a timeout that determines whether the intention of the pointer being down was to drag the navigation. This makes sure that touch users can use both the navigation and buttons.
onPointerUp
Resets both the pointerdown and dragging property

How PixiJS filters work

In PixiJs nomenclature, a filter is a class that is a wrapper around a fragment shader. The basic code to apply a filter to a PixiJS display object is:

const screenFilter = new ScreenFilter(2);
displayObject.filters = [this.screenFilter];

And for the whole time that filter sits in the array of that display object it will be rendered in place of the display object.

There are many ways to write filters for PixiJS but for our purposes we’re going to write classes that extend PIXI.Filter because we want to run a little extra code during the render loop for the filter.

The PixiJS filter itself creates a fragment shader and allows that shader to be run over any PixiJS display object, providing the ability to read the pixels that make up the object and limiting the render area to the bounds of that display object. This is very powerful as it limits the computational power required to run the shader.

For this demo, I’ve written two filters. One will run over the buttons themselves as they’re hovered or focused, the other will run over the navigation container itself.

Writing the filter for button hover

This filter runs only on the buttons and only when the buttons have been hovered or focused. It’s a reasonably straightforward filter that updates a time variable every frame for the purpose of animating some noise in the fragment shader. Please see the HoverFilter.fragmentSrc method for more information.

Writing the filter for the screen distortion

This filter runs on the display object for the navigation group of elements itself. It distorts and blurs the navigation in a radius around the position of the user’s mouse and, in combination with the animation of the navigation itself, this provides a sense of refraction, and distortion like looking at the navigation under water. Please see the ScreenFilter.fragmentSrc method for more information.

Making it accessible

We should be making websites for everybody. So this demo attempts to make a functional element that can be used by anybody. Keyboard users and screen readers are able to consume this navigation as easily as they would a normal website navigation and this is in thanks to the use of focus listeners.

Closing thoughts

In this demo we see that the power of webGL is wonderful. With it we can create really new and exciting things, and it allows us to take old ideas and make them new again.

Underwater.2019-04-24 17_44_59

In the source code for this demo you’ll be able to see how I’ve constructed the shaders that control the appearance of the navigation. In the process of building this I’ve tried to be as clear and straightforward as possible, so please try your hand at modifying the code. I would love to see what you can create with this basic setup, feel free to @me or message me on Twitter with anything you may think of.

Liam Egan

Liam Egan is the lead developer at We The Collective, a digital-first creative agency in Vancouver, BC.

Stay in the loop: Get your dose of frontend twice a week

Fresh news, inspo, code demos, and UI animations—zero fluff, all quality. Make your Mondays and Thursdays creative!

Feedback 11

Comments are closed.
  1. Your demo stops my entire phone for a minute. it seems that Rendering is not mobile-friendly. 😉

    • What mobile device? In a production environment we’d more carefully sniff out and disable for troublesome devices. That said, there’s not much going on here that should freeze up your device.

  2. I want to hide cursor on canvas text, can i get any help?
    great Tutorial, thank you so much.

  3. Great explanation! I love how detailed your code comments are, which helps a lot for someone like me who’s really slow at grasping concepts! Thank you so much for this!