Craft CMS AJAX Page Transitions with history.pushState

Creating a seamless user experience with minimal load times and no hard refreshing when navigating through the site

I’ll explain the basics of how I handle AJAX page transitions for Craft CMS using history.pushState, starting with an example of what you can achieve with little modification of the code:

I've used similar variations of this code on a few different sites now and it's worked great.

What I’ll cover:

  • Creating the AJAX template layout
  • Modifying the entry templates
  • Adding new extends logic
  • Including an element that wraps all the content to animate with
  • Simple fade in/out page transition with Velocity.js

What will be up to you:

  • JavaScript to handle updating the HTML title. This was left out because it depends on how you handle it in the first place, in terms of SEO
  • Any complex page transition animations. I recommend using Velocity.js
  • Integrating and modifying the code to work with your project

Creating The AJAX Template Layout

Compared to the normal layout that’s extended in your templates, you need to create an AJAX layout.

Your pre-existing _layout.twig will look similar to this:

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="en-US">
<head>
  {% include '_partials/head' %}
</head>

<body class="preload">

  {% include '_partials/header' %}

  <main class="js-main" role="main">
    {% block content %}
      <p>If you see me, you haven’t set your <code>{% verbatim %}{% block content %}…{% endblock %}{% endverbatim %}</code> yet.</p>
    {% endblock %}
  </main>

  {% include '_partials/scripts' %}

</body>
</html>

The new _ajax-layout.twig will be just the content block:

{% block content %}
  <p>If you see me, you haven’t set your <code>{% verbatim %}{% block content %}…{% endblock %}{% endverbatim %}</code> yet.</p>
{% endblock %}

This is because we only want to get the content that’s different between each page. On most sites the header/navigation and footer stay the same.

Modifying the Entry Templates

Adding New Extends Logic

Normally you’d extend your layout file in all of your templates like this:

{% extends '_layout' %}

But for AJAX page transitions you don't want to get the whole page, you just need the new content or the new _ajax-layout.twig file. So we do something like this:

{% extends (craft.request.isAjax and not craft.request.isLivePreview) ? "_ajax-layout" : "_layout" %}

This checks to see if the request is AJAX rather than Live Preview. If we didn't check for Live Preview, then that feature wouldn't work correctly. You’d only see the entry content and not the whole page in Live Preview.

Note that this needs to be a ternary operator because you can’t have more than one extends in a template, per the Twig documentation.

So, go through and replace all of your default {% extends %} in each of your templates with the updated AJAX version.

Wrapper Element, Used for Animation

The way I handle the AJAX transition animations with Velocity.js is by animating a parent element that wraps all the content on each page. You might find way that works better for your project, but this works flawlessly for me.

Before

{# The new extends logic #}
{% extends (craft.request.isAjax and not craft.request.isLivePreview) ? "_layout-ajax" : "_layout" %}

{% block content %}
  {{ entry.body }}

  <h2>Recent Blogs</h2>
  <ul>
    {% for entry in craft.entries.section('blog').limit(5).find() %}
      <li><a href="{{ entry.url }}">{{ entry.title }}</a></li>
    {% endfor %}
  </ul>
{% endblock %}

After

{# The new extends logic #}
{% extends (craft.request.isAjax and not craft.request.isLivePreview) ? "_layout-ajax" : "_layout" %}

{% block content %}
  <div class="js-ajax-wrapper">

    {{ entry.body }}

    <h2>Recent Blogs</h2>
    <ul>
      {% for entry in craft.entries.section('blog').limit(5).find() %}
        <li><a href="{{ entry.url }}">{{ entry.title }}</a></li>
      {% endfor %}
    </ul>

  </div>
{% endblock %}

JavaScript: Using history.pushState

To make links on the site use history.pushState:

$(function() {
  $(document).on('click', 'a', function() {
    var href = $(this).attr("href");

    history.pushState({}, '', href);
    $main.load(href);
    return false;
  });
});

This uses history.pushState to add the href attribute of clicked <a> tags to the browser history.

It returns false to prevent the browser from following that link.

Note that I delegated the click event. This is because if we did something like…

$('a').on('click', function() {
  var href = $(this).attr("href");

  history.pushState({}, '', href);
  $main.load(href);
  return false;
});

…any new <a> tags that are clicked will operate like normal and won't use history.pushState. This applies to most things throughout your JavaScript. You’ll need to make sure that everything still works after being added to the DOM.

Most sites have a consistent header and footer that don't change, and a main content area that does. So we need to use AJAX to replace existing content with new content.

I've opted to use the symantic <main> tag with a .js-main class, which will hold all the content that’s replaced and added:

$(function() {
  var $main = $('.js-main');

  $(document).on('click', 'a', function() {
    var href = $(this).attr("href");

    history.pushState({}, '', href);
    $main.load(href);
    return false;
  });
});

We're going to need to be able to run code when a page loads, for both regular and AJAX page loads. So we'll add functions to handle both of those:

$(function() {
  var $main = $('.js-main'),

  /* ----- Do this when a page loads ----- */
  init = function() {
    /* ----- This is where I would run any page specific functions ----- */
  },

  /* ----- Do this for ajax page loads ----- */
  ajaxLoad = function(html) {
    init();

    /* ----- Here you could maybe add logic to set the HTML title to the new page title ----- */
  },

/* ----- This runs on the first page load with no ajax ----- */
  init();

  $(document).on('click', 'a', function() {
    var href = $(this).attr("href");

    history.pushState({}, '', href);
    $main.load(href);
    return false;
  });
});

To ensure that external links open normally, we need to check that we're using history.pushState only on internal links:

$(function() {
  var $main = $('.js-main'),

  /* ----- Do this when a page loads ----- */
  init = function() {
    /* ----- This is where I would run any page specific functions ----- */
  },

  /* ----- Do this for ajax page loads ----- */
  ajaxLoad = function(html) {
    init();

    /* ----- Here you could maybe add logic to set the HTML title to the new page title ----- */
  },

  loadPage = function(href) {
    $main.load(href + 'main>*', ajaxLoad);
  };

  /* ----- This runs on the first page load with no ajax ----- */
  init();

  $(document).on('click', 'a', function() {
    var href = $(this).attr("href");

    if (href.indexOf(document.domain) > -1 || href.indexOf(':') === -1) {
      history.pushState({}, '', href);
      loadPage(href);
      return false;
    }
  });
});

Now let's make the browser’s Back and Forward buttons work with the AJAX transition. We'll listen for the popstate event, and ensure that we only act upon that event if there's already been a page change:

$(function() {
  var $main = $('.js-main'),
      changedPage = false,

  /* ----- Do this when a page loads ----- */
  init = function() {
    /* ----- This is where I would run any page specific functions ----- */
  },

  /* ----- Do this for ajax page loads ----- */
  ajaxLoad = function(html) {
    init();

    /* ----- Here you could maybe add logic to set the HTML title to the new page title ----- */

    /* ----- Used for popState event (back/forward browser buttons) ----- */
    changedPage = true;
  },

  loadPage = function(href) {
    $main.load(href + 'main>*', ajaxLoad);
  };

  /* ----- This runs on the first page load with no ajax ----- */
  init();

  $(window).on("popstate", function(e) {
    // -------------------------------------
    //   If there was an AJAX page transition already,
    //   then AJAX page load the requested page from the back or forwards button click.
    //   Variable initially set after the $main variable.
    // -------------------------------------
    if (changedPage) loadPage(location.href);
  });

  $(document).on('click', 'a', function() {
    var href = $(this).attr("href");

    if (href.indexOf(document.domain) > -1 || href.indexOf(':') === -1) {
      history.pushState({}, '', href);
      loadPage(href);
      return false;
    }
  });
});

At this point, the AJAX page transition’s a simple remove and replace. Let's spruce that up by adding some animations. I'll use Velocity.js with the Velocity UI Pack, replacing the contents of the loadPage function:

$(function() {
  var $main = $('.js-main'),
      changedPage = false,

  /* ----- Do this when a page loads ----- */
  init = function() {
    /* ----- This is where I would run any page specific functions ----- */
  },

  /* ----- Do this for ajax page loads ----- */
  ajaxLoad = function(html) {
    init();

    /* ----- Here you could maybe add logic to set the HTML title to the new page title ----- */

    /* ----- Used for popState event (back/forward browser buttons) ----- */
    changedPage = true;
  },

  loadPage = function(href) {

    $main.wrapInner('<div class="new-results-div" />');

    /* ----- Set height of $main to ensure the footer doesn't jump up -----  */
    var newResultsHeight = $('.new-results-div').outerHeight();
    $main.height(newResultsHeight);

    $('.js-ajax-wrapper').velocity('transition.fadeOut', {
      /* ----- Upon completion of animating out content put user at top of page. ----- */
      complete: function(){
        $('html').velocity("scroll", {
          duration: 0,
          easing: "ease",
          mobileHA: false
        });
      }
    });

    $.ajax({
      type: 'POST',
      url: href,
      data: {},
      success: function(result){
        /* ----- Where the new content is added ----- */
        $main.html(result);

        /* ----- Wrap content in div so we can get it's height ----- */
        $main.wrapInner('<div class="new-results-div" />');

        /* ----- Get height of new container inside results container and set $main to it so there's no content jumpage -----  */
        var newResultsHeight = $('.new-results-div').outerHeight();
        $main.height(newResultsHeight);

        /* ----- Bring In New Content ----- */
        $('.js-main .js-ajax-wrapper').velocity('transition.fadeIn', {
          visibility: 'visible',
          complete: function() {
            /* ----- Removes the temp height from $main ----- */
            $main.css('height', '');

            ajaxLoad();
          }
        });
      },
      error: function(){
        console.log("error.");
        location.reload();
      }
    });

  };

  /* ----- This runs on the first page load with no ajax ----- */
  init();

  $(window).on("popstate", function(e) {
    // -------------------------------------
    //   If there was an AJAX page transition already,
    //   then AJAX page load the requested page from the back or forwards button click.
    //   Variable initially set after the $main variable.
    // -------------------------------------
    if (changedPage) loadPage(location.href);
  });

  $(document).on('click', 'a', function() {
    var href = $(this).attr("href");

    if (href.indexOf(document.domain) > -1 || href.indexOf(':') === -1) {
      history.pushState({}, '', href);
      loadPage(href);
      return false;
    }
  });
});

That's it for the base code that handles AJAX page transitions with history.pushState. At this point, you should be able to navigate through your Craft CMS site with an animated AJAX page transition.

If you have any questions, please visit the Craft CMS StackExchange.

Useful resources

Interested in learning more about Craft and why we love it? Get in touch.

Let's Chat

We'd love to hear about your goals and how we can help you reach them.

Get Started