Using aria-current for active nav link in NextJS app
July 26, 2021• ☕️☕️ 7 min read
What is aria-current?
As per W3C definition, aria-current attribute can be used to identify or visually style an element among a set of related items. It can take multiple token values: page
, step, location
, date
, time
, false
and true
.
The page
token can be used to indicate the currently active link among other navigation links. It is equally helpful for users relying on assistive technologies to recognise the currently displayed page. For example, if you are on Home
page, the screen reader would announce something like “current page, Home”.
<ul>
<li><a href="/" aria-current="page">Home</a></li>
<li><a href="/about">About</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
And you can now easily style the current home page by using the CSS attribute selector:
a[aria-current] {
color: red;
}
With the definition aside, let’s see how we can inject this attribute dynamically into a NextJS Link tag. It’s way simpler than you think, and there’s no excuse for avoiding it. Moreover, this idea can also be applied to other applications, as long as you can get hold of the current URL path.
Injecting aria-current into the link tag
NextJS provides us with Link component for client-side routing. It has useRouter hook to extract the current route (path as shown in the browser) too. We can easily use it to inject aria-current
attribute like in the following example:
const NavLink = ({ href, name }) => {
const { asPath } = useRouter();
const ariaCurrent = href === asPath ? "page" : undefined;
return (
<Link href={href}>
<a aria-current={ariaCurrent}>{name}</a>
</Link>
);
};
To test it, you can open the voiceover app in the mac via Command + F5
, and when the current active link gets the focus, the voiceover will announce current page
along with the link name. This is a nice improvement for such a small change. Your users will thank you for this.
The approach remains similar if you’re using a basePath in your next.config.js
, as basePath
value is not included in router.asPath
.
The curious case of dynamic routing with custom server
The suggested solution would cover most of your use cases, including the one where your navigation links are generated based off some dynamic routes at run time. And that was the reason we relied upon router.asPath
instead of router.pathname
.
The reason being that the router.pathname
would return placeholder (slug) values instead of actual dynamic route values. For example, if you have a pages/posts/[pid].js
as your dynamic path, the router.pathname
would return /posts/[pid]
, which will be different from your href
value at run time. This will break your solution as araiCurrent
will be undefined
and no aria-current
props will be passed down to your anchor
element.
But, what about that curious case?
Here’s the GIF displaying how our first solution fails, and active page link is not highlighted on first page load.
Note how it starts highlighting (colour: red) the correct active page link when we transition to another route (client-side).
Well, that curious case could be the one which meet the following conditions:
- custom server using express (NextJS)
- configured with a
basePath
- all server-rendered pages (using getServerSideProps)
- navigation links are generated at run time for dynamic routes
// router.js
const router = express.Router();
// Our configured base path inside next.config.js file
router.get(/${basePath}/posts/:pid, (req, res) => {
app.render(req, res, '/posts/[pid]', req.params);
});
// server.js
/**
* Custom NodeJS server for our NextJS app
* Showing only important bits for the sake of code brevity
*/
import router from './router';
app.prepare().then(() => {
const server = express();
server.use(router);
server.listen(4000);
return server;
}).catch(err => console.log(err));
Now, for your server-rendered pages (which contain your nav links), if you try to print
the value of console.log(router.asPath)
inside your NavLink
component above, you will find an interesting fact: the client-side value is different from the server-side value.
The client printed value will not include basePath
in the URL, but server-side does. Considering the dynamic post route example with /custom
configured as basePath
, you might find the results like as shown in the screenshot below:
This discrepancy in the result could break our suggested solution. And that’s what took me by surprise, too. You may wonder why we are even talking about server output? Why don’t we just grab the client’s output and be done with it? After all, our logic (href === asPath) is purely a client side thing—compare href with the URL shown in the browser.
I was thinking along the same line, hoping that if the sever-rendered HTML is different from client-side, then React would at least try to patch things up, making the rendering
part win over the hydrating
part. But as the React official docs describes that it’s not guaranteed that React will patch up attribute mismatches.
I was pulling my hair out. Our comparison was returning false
and ariaCurrent
was becoming undefined
on the first render. Nonetheless, making client-side transitions after the page load was injecting correct aria-current
value as expected.
The solution
Let’s try to solve the problem with existing information/result we have:
- the
router.asPath
is equal tohref
attribute at client-side - client-side route transitions fixes the problem automatically
Based on this fact, we can use useState hook to force re-render our NavLink
component after the mounting phase. This will make our state variable (ariaCurrent) stores the expected value.
Let’s see that in action:
const NavLink = ({ href, children }) => {
// We need a setter to cause a re-render
const [ariaCurrent, setAriaCurrent] = useState();
const { asPath } = useRouter();
useEffect(() => {
const ariaCurrent = href === asPath ? "page" : undefined;
/* You could even create your own custom hook,
* for e.g. `useForceUpdate()` if you like.
*
* Calling the setter, would cause a re-render
*/
setAriaCurrent(ariaCurrent);
}, [asPath, href]);
return (
<Link href={href}>
<a aria-current={ariaCurrent}>{children}</a>
</Link>
);
};
Here’s the GIF showing the fix with our new implementation:
Summary
In this article, we looked into an easy solution to not only visually style currently active link among the set of related page links, but also how to make it more accessible to the users relying on assistive technologies.
Additionally, we looked into an interesting discrepancy with the first solution when your navigation links are generated at run time based on dynamic paths. The working solution we discussed required us to make our NavLink
component re-render after the first page load. I understand that we are doing unnecessary rendering, but I think the benefits it provides are way more than the rendering cost I am willing to take.
Here’s the GitHub repo link for the custom server setup we discussed. The main
branch carries the bug (the broken solution), and inject-aria-current-dynamic-routes
branch carries the fix. Go and see it for yourself.
If you’ve a better solution to tackle this problem, or want to share your ideas in general over this article, please feel free to drop your comment in the box below. Stay safe and happy coding 😍.
Written by Amandeep Singh. Developer @ Avarni Sydney. Tech enthusiast and a pragmatic programmer.