Smooth Scrolling and Accessibility

Avatar of Heather Migliorisi
Heather Migliorisi on

UGURUS offers elite coaching and mentorship for agency owners looking to grow. Start with the free Agency Accelerator today.

Smooth scrolling (the animated change of position within the viewport from the originating link to the destination anchor) can be a nice interaction detail added to a site, giving a polished feel to the experience. If you don’t believe me, look at how many people have responded to the Smooth Scrolling snippet here on CSS-Tricks.

smooth scroll vs abrupt jump
Smooth scrolling vs abrupt jumps

Regardless of how you implement the feature, there are a few accessibility issues that should be addressed: focus management and animation.

Focus Management

It is important to ensure that all content can be accessed with the keyboard alone because some users 100% rely on the keyboard for navigation. So, when a keyboard user navigates through the content and hits a link that uses smooth scrolling, they should be able to use it to navigate to the target anchor element.

In other words, when you follow a link, the keyboard focus should follow it, too and be able to access the next element after the target. Here is an example of links to page anchors where focus is maintained because there is no JavaScript used:

Example where focus is maintained
Default browser behavior with links to page anchors and focus is properly maintained.

Try it for yourself: use the tab key to navigate using this demo. Please note that Safari/WebKit has an outstanding bug regarding keyboard focus.

Original jQuery Example

Let’s look at the jQuery example from the original post:

$(function() {
  $('a[href*="#"]:not([href="#"])').click(function() {
    if (location.pathname.replace(/^\//,'') == this.pathname.replace(/^\//,'') && location.hostname == this.hostname) {
      var target = $(this.hash);
      target = target.length ? target : $('[name=' + this.hash.slice(1) +']');
      if (target.length) {
        $('html, body').animate({
          scrollTop: target.offset().top
        }, 1000);
        return false;
      }
    }
  });
});

This is implemented on a page from the W3C:

Smooth scroll on the W3C website
the Skip Nav should :focus on the content, but this does not change focus

Here, we see the “Skip to Content” link is not setting focus on the content that was navigated to. So, if we use this example, we make the navigation worse for folks using the keyboard because the user expects to be navigating to the content that is targeted, but they’re not, because focus is not updated to reflect the change.

Try it for yourself using the tab key to navigate with this demo.

What Went Wrong?

Why doesn’t this work? We’re using JavaScript to take over the normal browser linking behavior (note the URL never updates with the /#target) which means we need set the focus with JavaScript. In jQuery that would be $(target).focus();.

In order for this to work on non-focusable target elements (section, div, span, h1-6, ect), we have to set tabindex="-1" on them in order to be able to $(target).focus();. We can either add the tabindex="-1" directly on non-focusable target elements in the html markup or add it using JavaScript as seen here.

$(function() {
  $('a[href*="#"]:not([href="#"])').click(function() {
    if (location.pathname.replace(/^\//,'') == this.pathname.replace(/^\//,'') && location.hostname == this.hostname) {
      var target = $(this.hash);
      target = target.length ? target : $('[name=' + this.hash.slice(1) +']');
      if (target.length) {
        $('html, body').animate({
          scrollTop: target.offset().top
        }, 1000);
        target.focus(); // Setting focus
        if (target.is(":focus")){ // Checking if the target was focused
          return false;
        } else {
          target.attr('tabindex','-1'); // Adding tabindex for elements not focusable
          target.focus(); // Setting focus
        };
        return false;
      }
    }
  });
});

Try it for yourself using the tab key to navigate with this demo. Don’t forget your :focus styling!

Focus functionality on scroll on the W3C site

A Better Way?

It might be better for users in general if we handle this feature without hijacking the normal browser navigation behavior. For example, if you follow a link, you can go back with the browser back button. Also, you can bookmark (or copy and paste) the current URL and the browser will go to that specific destination from the last link you clicked.

// URL updates and the element focus is maintained
// originally found via in Update 3 on http://www.learningjquery.com/2007/10/improved-animated-scrolling-script-for-same-page-links

// filter handling for a /dir/ OR /indexordefault.page
function filterPath(string) {
  return string
    .replace(/^\//, '')
    .replace(/(index|default).[a-zA-Z]{3,4}$/, '')
    .replace(/\/$/, '');
}

var locationPath = filterPath(location.pathname);
$('a[href*="#"]').each(function () {
  var thisPath = filterPath(this.pathname) || locationPath;
  var hash = this.hash;
  if ($("#" + hash.replace(/#/, '')).length) {
    if (locationPath == thisPath && (location.hostname == this.hostname || !this.hostname) && this.hash.replace(/#/, '')) {
      var $target = $(hash), target = this.hash;
      if (target) {
        $(this).click(function (event) {
          event.preventDefault();
          $('html, body').animate({scrollTop: $target.offset().top}, 1000, function () {
            location.hash = target; 
            $target.focus();
            if ($target.is(":focus")){ //checking if the target was focused
              return false;
            }else{
              $target.attr('tabindex','-1'); //Adding tabindex for elements not focusable
              $target.focus(); //Setting focus
            };
          });       
        });
      }
    }
  }
});
Focus working on the W3C site
Example showing the URL updates with every anchor that is clicked

Here, the URL updates with every anchor that is clicked. Try it for yourself using the tab key to navigate using this demo.

Native Example

Let’s look at the native browser example from the CSS-Tricks post. (There is also a polyfill.)

document.querySelector('#target-of-thing-clicked-on').scrollIntoView({ 
  behavior: 'smooth' 
});

Unfortunately, with this method, we run into the same issue as the jQuery method where the page scrolls within the viewport, but does not update the keyboard focus. So, if we want to go this route, we would still have to set .focus() and ensure non-focusable target elements receive tabindex="-1".

Another consideration here is the lack of a callback function for when the scrolling stops. That may or may not be a problem. You’d move the focus simultaneously with the scrolling rather than at the end, which may or may not be a little weird. Anyway, there will be work to do!

Motion and Accessibility

Some people can literally get sick from the fast movement on the screen. I’d recommend a slow speed of the motion because if the user is going to jump across a lot of content, it can cause a dizzying effect if it’s too fast.

Also, it’s not a bad idea to offer users a way to turn off animations. Fortunately, Safari 10.1 introduced the Reduced Motion Media Query which provides developers a method to include animation in a way that can be disabled at the browser level.

/* JavaScript MediaQueryList Interface */
var motionQuery = window.matchMedia('(prefers-reduced-motion)');
if (motionQuery.matches) {
  /* reduce motion */
}
motionQuery.addListener( handleReduceMotionChanged );

Unfortunately, no other browsers have implemented this feature yet. So, until support is spread wider than one browser, we can provide the user an option via the interface to enable/disable animation that could cause users issues.

<label>
  <input type="checkbox" id="animation" name="animation" checked="checked">
  Enable Animation
</label> 
$(this).click(function(event) {
  if ($('#animation').prop('checked')) {
    event.preventDefault();
    $('html, body').animate({scrollTop: $target.offset().top}, 1000, function() {
      location.hash = target;
      $target.focus();
      if ($target.is(":focus")) {
        return !1;
      } else {
        $target.attr('tabindex', '-1');
        $target.focus()
      }
    })
  }
});

Try it for yourself with this demo.