Use Bootstrap 5.x Tabs with HTMX
Yes, we all love HTMX because after years of rolling our eyes at React&co1, it's our time to shine with our knowledge from the good old days of server-rendered HTML, pulled in via jQuery ajax calls.
No need to introduce HTMX because you are probably here to find out how to hx-get
HTML fragments into Bootstrap tabs.
The HTMX docs offer two ways to create tabs:
- Tabs (Using HATEOAS) - the state of the tabs is being rendered on the server
- Tabs (Using Hyperscript) - the state is managed by esoteric Hyperscript declarations.
Personally, I dislike both approaches and prefer to work within Bootstrap's constraints and use as many of their tools as possible. In this case for the tabbed interface, to control active state.
First I setup a really simple example and then I introduce a HTMX-Extension to avoid code duplication.
1. Basic Example
A typical Bootstrap .nav-link
tab and corresponding .tab-pane
looks like this: (I'll leave off all the attribute noise)
<nav>
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#nav-home">Home</button>
</nav>
<div>
<div class="tab-pane" id="nav-home">
Home Content
</div>
</div>
[data-bs-toggle="tab"]
initializes the tabs, [data-bs-target="#nav-home"]
points to .tab-pane#nav-home
and .show.active
said tab on click. Bootstrap takes care of the styles that reflect the state of the tabs, adds the .active
class to the .nav-link
and the .tab-pane
and changes the state if you click another tab.
Now loading a remote html fragment into a .tab-pane
could be done with a Bootstrap event:
const tabEl = document.querySelector('button[data-bs-toggle="tab"]')
tabEl.addEventListener('show.bs.tab', event => {
// ajax call to the server, inject html into pane...
})
Instead we use HTMX. Here again the very simplified code example:
<nav>
<button class="nav-link" hx-get="/home" hx-target="#nav-home" data-bs-toggle="tab" data-bs-target="#nav-home">Home</button>
</nav>
<div>
<div class="tab-pane" id="nav-home"></div>
</div>
The attribute [hx-get="/home"]
is the link to an HTML fragment, and [hx-target="#nav-home"]
tells HTMX in which pane the fragment will be injected.
This should all work out of the box. There might be some extra work to do, to smooth out CSS transitions. The tab will be activated by Bootstrap immediately, and stays blank until the HTML fragment was loaded!
To prevent the tab from being loaded from the server on every activation, I added this little snippet, which checks if there are HTML elements from a previous call already in the .tab-pane
and if yes, then just prevent the request.
htmx.on("htmx:beforeRequest", function(evt) {
if(evt.detail.target.hasChildNodes()){
// prevent another request
evt.preventDefault()
}
});
2. HTMX Extension
To reduce code duplication, [hx-target]
and [data-bs-target]
point to the same .tab-pane
[id]
, I just marked up the tabs with [data-bs-*]
attributes if I'm not using [hx-*]
Here is a ultra-minimal example:
<script src="/bs-tabs.js"></script>
<div hx-ext="bs-tabs">
<nav>
<button class="nav-link" hx-get="/home" hx-target="#nav-home">Home</button>
</nav>
<div>
<div class="tab-pane" id="nav-home"></div>
</div>
</div>
HTMX extensions are typically pulled in via an external script file. With [hx-ext="bs-tabs"]
I declare this part of the DOM as part of the extension.
Then I add the [hx-*]
attributes and the extension takes care of the rest of activating the Bootstrap behavior. The comments in the following code should help you to understand what's going on.
(function(){
htmx.defineExtension('bs-tabs', {
onEvent: function (name, evt) {
// before a request is made,
// check if there the content was already loaded into the tab pane
if (name === "htmx:beforeRequest") {
if(evt.detail.target.hasChildNodes()){
// stop request
return false;
}
}
if (name === "htmx:afterProcessNode") {
let allLinks = htmx.findAll(htmx.find('[hx-ext="bs-tabs"]'), '.nav-link');
// loop through all .nav-links
allLinks.forEach(triggerEl => {
// if the bootstrap data attribute for the target pane is missing...
if(!triggerEl.hasAttribute('data-bs-target')){
// ... add it. It's the same as the hx-target attribute.
triggerEl.setAttribute('data-bs-target', triggerEl.getAttribute('hx-target'));
}
// add the attribute that tells initializes the bootstrap functionality
triggerEl.setAttribute('data-bs-toggle', 'tab');
});
}
return true;
}
});
})();
Here is a working example. Btw. the HTMX folks way to set up demos that mock remote calls is genius!
These are very simple examples. They don't address the state of the tabs if you need to load a fragment on page load. I might add this later.
That's it. Let me know if there is something wrong or a better way. I'm still wrapping my head around the whole JS events and states. Also I hope this inspires more posts and examples of HTMX being used with other frontend frameworks.
Here is an example how to use Bootstraps form validation styles with HTMX!
because we couldn't understand how it works, at least I didn't ↩