In my last post, Shoelace Web Components with ASP.NET Core, I showed ASP.NET Core developers how to integrate the web components of Shoelace into an existing web application. It’s incredible, and you should check it out. If you’ve read this blog any time, you might have read a post or two about HTMX, a library for building dynamic and maintainable web experiences; I love it.

While the two libraries can work together, there’s an issue you need to work around to get the advantages of both. In this post, we’ll discuss the issues with using Shoelace with HTMX and how to get it working again with your ASP.NET Core applications. If you’re using any other technology stack (Django, Spring, or Next.js), don’t worry; the solution here will work for you, too.

Update in HTMX 2.0 and Shadow DOM support

For folks using HTMX 2.0, the HTMX library has since resolved this issue and you’re no longer required to perform the steps in this blog post. Users of HTMX pre-2.0 will still need the following blog post, but I would recommend upgrading to the latest version. The biggest changes in HTMX include moving all extensions out of the core library and making them opt-in.

I’ve confirmed the expected behavior in a branch on my GitHub Repository. Cheers. 🍻

Shoelace, the Shadow DOM, and HTMX

Shoelace is a web component library that encapsulates all functionality in a user-friendly HTML tag. Let’s take a look at the most common element, the Button.

After installing the Shoelace scripts, adding the sl-button element in HTML markup is straightforward.


<sl-button>Button</sl-button>

The abstraction works out nicely from a developer and user perspective, as these elements “just work” with HTML (as you might have read in the post mentioned earlier). Looking at how a client renders this element shows some of the implementation details.


<sl-button variant="default" size="medium" data-optional="" data-valid="">
    #shadow-root (open)
    <button part="base" class=" button button--default button--medium button--standard button--has-label " type="button"
            title="" name="" value="" role="button" aria-disabled="false" tabindex="0">
        <slot name="prefix" part="prefix" class="button__prefix"></slot>
        <slot part="label" class="button__label"></slot>
        <slot name="suffix" part="suffix" class="button__suffix"></slot>
        <!--?lit$16265820754$-->
        <!--?lit$16265820754$-->
    </button>
    Button
</sl-button>

Shoelace uses the Lit library to build components utilizing the Shadow DOM. These elements exist virtually in the DOM and are part of the page but may or may not be accessible based on the implementation.

As you likely guessed, HTMX doesn’t look at the Shadow DOM of custom web components to find common elements such as button, input, or select. This can be a problem for folks using HTMX to hijack form elements or adding hx- attributes to shoelace components. These custom components aren’t added to the ultimate request that HTMX builds.

Don’t worry; there’s an easy fix.

HTMX Events To The Rescue

HTMX has many events that allow you to intercept context at any point in the process. For our use case, we want to intercept all outgoing requests and determine if our target element and any of its children contain Shoelace components.

Luckily for us, Shoelace components follow a typical pattern of having name and value attributes. We can assume that any element with a name attribute is expected to be transmitted in a request.

Let’s hook into the htmx:beforeRequest. Add the following code into any script file that loads after HTMX and Shoelace libraries.

document.body.addEventListener('htmx:beforeRequest', evt => {
    const elements = [
        evt.target,
        ...evt.target.querySelectorAll('*')
    ];

    for (const el of elements) {
        const {tagName, name, value, disabled, checked} = el;

        // ignore inputs that aren't from shoelace
        if (!tagName.startsWith("SL-")) continue;
        // all inputs can be disabled
        if (disabled) continue;
        // the name is required
        if (name === undefined || name === "") continue;
        if (value === undefined) continue;

        // a checkable element
        if (checked !== undefined) {
            if (checked) {
                evt.detail.requestConfig.parameters[name] = value;
            }
        } else {
            // it is a simple element
            evt.detail.requestConfig.parameters[name] = value;
        }
    }
});

Note that form values can have duplicate names within a form collection. I don’t commonly duplicate names, but it might be a typical pattern for arrays in other technology stacks. If so, you may need to alter the code above to suit your needs.

As an additional note, there may be some components you need to account for in your server code. For example, sl-select component works with arrays.

The current value of the select, submitted as a name/value pair with form data. Whenmultipleis enabled, the value attribute will be a space-delimited list of values based on the options selected, and the value property will be an array. For this reason, values must not contain spaces.Shoelace Documentation

So far, in my testing, this code works with most of the shoelace elements in the official documentation. That said, please test and modify the code according to your needs.

Conclusion

It’s always fun when things work together, but it’s not always the case. Luckily, both HTMX and Shoelace offer great APIs that allow you to smooth out some of these issues. Please try these two libraries in your ASP.NET Core applications and let me know how it goes.

As always, thanks for reading the posts, and I hope I helped you get on your way to a productive day. Cheers.

*Special thanks to Mario Hamann and his excellent post integrating Web Components and Livewire.