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:

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!


  1. because we couldn't understand how it works, at least I didn't