In case you haven’t heard of it, Alpine.js is a lightweight but very powerful JavaScript library. As the creator Caleb Porzio says, think of it like “jQuery for the modern web”.
It’s a great way to add simple interactive elements to your WordPress theme, and something I’ve been really enjoying using lately.
Compared to working in jQuery it’s a real breath of fresh air.
One key difference when using Alpine.js, is that rather than creating and enqueuing a separate JavaScript file, as you would with jQuery, you add directives and event listeners directly to your HTML markup. When using Alpine.js with Genesis, this means we need to utilise the Markup API.
Creating a simple responsive menu
One feature which was sadly lacking in my new blog theme was a responsive menu. My needs were quite simple though, so let’s outline how the mobile menu should work:
- On mobile, a toggle button should be shown and the primary navigation hidden.
- When the button is tapped the primary navigation becomes visible.
- If the button is tapped while the navigation is visible, then the navigation is hidden.
- On a larger screen the toggle button should be hidden, and the primary navigation visible.
Those seem like straightforward requirements. It would also be nice if we could give our toggle button and our primary navigation the correct aria-expanded
automatically when they’re opened or closed.
Getting started
The first thing to do is to add Alpine.js to our project. In the WordPress theme world, this means enqueueing the script, and in my theme, that’s in the functions.php
file.
I’ve already got a function for loading scripts and stylesheets, so I’ll add my wp_enqueue_script
call in there, as follows:
1wp_enqueue_script( 'alpine', 'https://unpkg.com/alpinejs@3.10.2/dist/cdn.min.js', [], '3.10.2', false );
It’s important to note that I’m loading the script in the document <head>
as in the installation instructions. And I also have to add some code which will add the defer
attribute to the script tag. To do that in my theme I used the following code:
1add_filter( 'script_loader_tag', 'ampersand_filter_script_loader_tag', 10, 2 ); 2/** 3 * Filter the script loader to defer Alpine.js. 4 * 5 * @return string 6 */ 7function ampersand_filter_script_loader_tag( $tag, $handle ) { 8 9 if ( 'alpine' !== $handle ) {10 return $tag;11 }12 13 return str_replace( ' src', ' defer="defer" src', $tag );14}
And now we have Alpine loaded, and we can begin to work on our mobile menu.
Adding Alpine directives to our site header
We’re adding three Alpine directives to our site header, x-data
, x-init
and x-on:resize.window
using the Genesis Markup API.
The first of these directives, x-data
defines the root of our component – all of our interactivity is scoped to within the site header. Elements within the site header can access the properties and expressions within the x-data
object, but those outside it cannot.
So later, when we add the toggle button just after the title area and just before our navigation menu it’ll be inside our component scope, and we’ll be able to use it to update the visible
property inside x-data
.
Inside our x-data
object, I’ve also thought ahead and added an expression called headerResizeHandler()
which we’ll call upon to check if the screen is wide enough to show the menu in its entirety. In this case, if the screen width is greater than or equal to 540 pixels, then our visible
property should be true, and the navigation is shown.
The other two directives, x-init
and x-on:resize.window
call our expression. Once on page load, by x-init
, and then any time the window is resized, by x-on:resize.window
.
1add_filter('genesis_attr_site-header', 'ampersand_add_alpine_directives_to_site_header' ); 2/** 3 * Filter the site-header attributes and add Alpine.js directives. 4 * 5 * @param array $attributes Existing site-header attributes. 6 * 7 * @return array 8 */ 9function ampersand_add_alpine_directives_to_site_header( $attributes ) {10 return array_merge( $attributes, [11 'x-data' => '{ visible: false, headerResizeHandler() { this.visible = window.innerWidth >= 540 } }',12 'x-init' => 'headerResizeHandler()',13 'x-on:resize.window' => 'headerResizeHandler()',14 ] );15}
Adding Alpine directives to our navigation
I also need to filter the attributes on the primary navigation, though these are a little easier to explain. The id
attribute will be used later when I add the toggle button so that I can use the aria-controls
attribute to make it clear that the toggle button controls the primary navigation.
The x-show
directive is used by Alpine to determine whether the navigation will be shown or hidden. It’s set to visible
which is the name of the property in the x-data
directive attached to the site header that decides whether the header is shown. So if visible is false, which it is by default, then x-show is false, and the navigation is hidden.
I’m also adding the :aria-expanded
directive, the value of which will again be determined by the value of the visible
property from x-data
.
1add_filter('genesis_attr_nav-primary', 'ampersand_add_alpine_directives_to_nav_primary' ); 2/** 3 * Filter the nav-primary attributes and add Alpine.js directives. 4 * 5 * @param array $attributes Existing site-header attributes. 6 * 7 * @return array 8 */ 9function ampersand_add_alpine_directives_to_nav_primary( $attributes ) {10 return array_merge($attributes, [11 'id' => 'genesis-nav-primary',12 'x-show' => 'visible',13 ':aria-expanded' => 'visible',14 ]);15}
Adding a button to toggle navigation visibility
This is the most straightforward part of the implementation. Our navigation is added to the site header at priority 13, so we’re using the available Genesis hooks, and inserting the button at priority 12, just before.
Our button element contains a simple hamburger icon from Heroicons and some assistive text shown only to screen readers.
It has two additional Alpine directives, x-on:click
with a simple expression that sets our visible
property to its opposite value. So if it’s true, then clicking the button will make it false, and vice versa.
And :aria-expanded="visible"
, the same as we added to the primary navigation, which will add the correct aria-expanded
attribute based on the value of our visible
property.
1<?php 2add_action( 'genesis_header', 'ampersand_add_menu_toggle_before_nav_primary', 12 ); 3/** 4 * Add mobile navigation toggle before the primary navigation. 5 * 6 * @return void 7 */ 8function ampersand_add_menu_toggle_before_nav_primary() { 9 ?>10 <button class="nav-menu-toggle" x-on:click="visible = ! visible" :aria-expanded="visible" aria-controls="genesis-nav-primary">11 <svg xmlns="<http://www.w3.org/2000/svg>" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">12 <path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />13 </svg>14 <span class="screen-reader-text">Toggle Menu Visibility</span>15 </button>16 <?php17}
Adding some basic styling
I haven’t touched on any of the CSS that’s needed to make this work, and that’s because there’s very little. You might want to use CSS to:
- Give the navigation toggle some appropriate styling, and the SVG icon width and height.
- Make sure the navigation stacks on small screen devices, then return to a row on a large screen.
- Hide the toggle button on larger screen devices.
Hiding the toggle button is something we could also use Alpine for, but the effort is more than it would take to add a simple CSS media query that hides it.
Next steps
There are a couple of ways I’d really like to move forward with this simple responsive menu, and before I can roll it out to premium themes.
- Adding support for sub-menus.
At the moment we’re only working with one-level navigation menus, but really we need to add an Alpine.js component on the navigation itself, and then do the same for each submenu contained within. - Extracting to a separate package or library.
I don’t want to have to copy and paste this code between themes, I would rather extract it into a library loaded with Composer, or a simple WordPress plugin.