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.
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:
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:
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!
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
};
});
});
}
}
}
});
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.
This is a really interesting topic. The push toward motion and animation is intended to enhance your spatial orientation in an otherwise two-dimensional UI, and that’s pretty interesting – and clearly popular. You mention that
but it’s not just some – it’s a lot. Mild vestibular disorders are super common in men, and while it’s not going to make folks up-chuck, it will definitely be a net-negative user experience, be jarring, and otherwise undesirable.
The key is control: if you’re tabbing around and you tab below viewport, then the site smooth-scrolls, in my experience the sensation is one of lag and uncontrolled motion. So, first, I’m like: uh how come the viewport hasn’t changed yet; second: bleaaargh [vomit noise]. You get the idea.
The viewport should change at the rate of focus, and you can tab pretty fast.
On the flipside, if you use a scroll wheel or a swipe or engage with the scroll bar to intentionally scroll – then animating that scroll is pretty harmless. But, I’d argue, keyboard navigation != scroll intent. These are different and disparate inputs, with different and disparate conventions.
@Michael Schofield – Yep! We never want to make people sick just because they’re visiting a website. Hopefully, more sites will offer folks the option to turn animations off OR more browsers will step in and offer a solution like Safari has (this is what I’m rooting for).
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!
I can confirm it’s a problem. The fact to focus() the target hijacks the smooth scrolling and it jumps right at the target, abruptly.
I guess the way around it would be a setTimeout() with the same duration as the scrolling, and you change focus inside that. Doesn’t feel particularly elegant, tho.
Or turn to the animationend event route
The easiest upcoming method is definitely
scroll-behavior
in CSS::root {
scroll-behavior: smooth;
}
Awesome!
scroll-behavior
would remove the need for JS (for the scrolling action) and negate the need for focus management when moving to focusable elements. I’m really looking forward to this feature being implemented by browsers.However, one way or another, we’d still need to make unfocusable elements (section, div, span, h1-6, ect) focusable with
tabindex="-1"
.In addition, we still need to offer a way to disable the scroll for folks that find the movement dizzying.
Even still, this looks like a great CSS feature! Thanks for sharing!
It’s worth noting that there is also a corresponding CSS scroll-behavior property, which can be set to
smooth
. I think, this makes it possible to have both smooth scrolling and keyboard accessibility without JavaScript (in supporting browsers, of course).You can check wether an element is focusable by script with the following code:
You could do something like this:
Note: use
location.hash
, notthis.hash
.Reduced Motion Media Query. Now that’s something I didn’t know, and am very glad to learn about. Thanks, Chris.
To achieve keyboard-friendly smooth scrolling as a progressive enhancement and without jQuery you can do something like this with Fetch Injection:
const el = document.querySelector('details summary')
el.onclick = (evt) => {
fetchInject([
'https://cdn.jsdelivr.net/smooth-scroll/10.2.1/smooth-scroll.min.js'
])
el.onclick = null
}
Additional information on Fetch Injection can be found on Hack Cabin.
Nice article and I like the idea of offering user the possibility to disable animations.
In the last demo, the back button does not work correctly in FF 52.0.2 : The URL has ‘null’ as last path component before the hash.
Actually, I misread the URL: this is not ‘null’ but ‘nujibey’. My bad. Still, back button does not work as expected :)
Am I missing something? Why play with :focus when we can trigger the event after our scroll animation?
If we trigger a hashchange then we automatically reset the tabindex. Here’s how it works:
If the target isn’t focusable, you should add a negative tabindex (or 0) and then set focus on it.
Actually you don’t. The tabindex is set to the correct value when you change the window.location. Check the pen