Skip to Main Content

Defer Loading Modal Content with Sprig and Alpine.js

Posted on under Craft Development

I was working on a project where I had a table of user information and a button that would open a modal to take some kind of action on that user's account. The modal data required calling some additional queries and conditional logic, which I knew would slow things down.

I came across this tweet that demonstrated using Laravel Livewire to dynamically load modal content when opened. I shot a tweet to @ben_pylo (the creator of Sprig) to confirm this was possible with Sprig. Not only is it possible, but it's actually quite simple.

View the Demo

If you want to see a preview or view the final component code, check out this project on the Playground

Sprig

Creating the Sprig Component

I've been using Sprig a lot lately and put all of my Sprig files into their own folder, e.g. templates/_sprig. So in this example, let's create _sprig/modal.twig. There are two "sections" to a Sprig component like this: the part that is rendered on page load and the part that is loaded dynamically when the Sprig component is triggered. We can distinguish the two using these variables:

  
      {# Pre-rendered like everything else #}

{% if sprig.isInclude %}

{% endif %}


{# Dynamically Loaded when Sprig is triggered #}

{% if sprig.isRequest %}

{% endif %}  

With that structure set up, we can now setup some basic markup to better visualize where things will go.

  
      {# Pre-rendered like everything else #}

{% if sprig.isInclude %}
  <div class="component-wrapper">
    <button>
      Open the Modal
    </button>

    <div class="modal">
      <div class="modal-content">
        <div class="modal-header"></div>
        <div class="modal-body"></div>
        <div class="modal-footer"></div>
      </div>
    </div>
  </div>
{% endif %}


{# Dynamically Loaded when Sprig is triggered #}

{% if sprig.isRequest %}
  {# Call any Craft queries here #}
  <div>Fetched Modal Content</div>
{% endif %}  

This might look confusing since the modal markup is within the sprig.isInclude chunk and there is very little inside the sprig.isRequest chunk. Remember, it's not the modal markup itself we want/need to defer, it's the contents of the modal, so that is all we need Sprig to request for us.

Adding Sprig Attributes

The next step is to add the necessary sprig attributes.

  
      {# Pre-rendered like everything else #}

{% if sprig.isInclude %}
  <div class="component-wrapper">
    <button sprig s-target="#dynamic-modal-content">
      Open the Modal
    </button>

    <div class="modal">
      <div class="modal-content">
        <div class="modal-header"></div>

        <div id="dynamic-modal-content" class="modal-body">
        </div>

        <div class="modal-footer"></div>
      </div>
    </div>
  </div>
{% endif %}


{# Dynamically Loaded when Sprig is triggered #}

{% if sprig.isRequest %}
  {# Call any Craft queries here #}
  <div>Fetched Modal Content</div>
{% endif %}  

Here I've added the sprig and s-target attributes to our button that opens the modal. The sprig attribute lets Sprig know what element to listen to while s-target tells sprig where to put the data when the request has returned content. In this case, our "Fetched Modal Contents" placeholder content will be inserted in to the #dynamic-modal-content div.

innerHTML is the default target swap, but this can be adjusted according to your needs. See swapping options here.

One more helpful thing you can do with Sprig is assign an indicator element, which will have classes added and removed from it while the component is changing. This means you can do your own animations or transitions on that specific element.

  
      <button sprig s-target="#dynamic-modal-content" s-indicator="#dynamic-modal-content">
  Open the Modal
</button>  

Alpine.js

To get the modal to actually work as we expect it to, we need to use some kind of JavaScript to handle the modal's state. Alpine is perfect for this because it's lightweight and markup-based. Let's see what this looks like.

  
      {# Pre-rendered like everything else #}

{% if sprig.isInclude %}
  <div class="component-wrapper" x-data="modal()">
    <button 
      sprig 
      s-target="#dynamic-modal-content" 
      s-indicator="#dynamic-modal-content" 
      x-on:click="open"
    >
      Open the Modal
    </button>

    <div x-cloak x-show="isOpen()" class="modal">
      <div class="modal-content" x-on:click.away="close">

        <div class="modal-header"></div>

        <div id="dynamic-modal-content" class="modal-body">
        </div>

        <div class="modal-footer">
          <button type="button" x-on:click="close">Close</button>
        </div>

      </div>
    </div>
  </div>
{% endif %}


{# Dynamically Loaded when Sprig is triggered #}

{% if sprig.isRequest %}
  {# Call any Craft queries here #}
  <div>Fetched Modal Content</div>
{% endif %}

<script>
  function modal(){
    return {
      show: false,
      open(){
        this.show = true;
      },
      close(){
        this.show = false;
      },
      isOpen(){
        return this.show === true;
      }
    }
  }
</script>  

In the example above, we put the script tag directly into our Sprig component. As of Sprig 1.1.0, script tags inside a component are executed whenever it is rendered.

Note:

If you really dislike script tags in your markup files, you can put this logic into a separate JS file or component. As per the Alpine docs, if you're using a bundler like webpack, you should set the modal function on the window object.

Extending

Accessibility

There are a few ways this method can be extended. First, it is good practice to make things like this more accessible by giving screen readers and assistive technology more context for what's happening on screen. Specific information can be found here.

Animations

Loading animations can be used to indicate that the modal contents are being loaded. This could be a spinner, a skeleton loader, or anything else. An example of a skeleton loader might look like this:

  
      {# This is inside the sprig.isInclude chunk #}
<div id="dynamic-modal-content" class="modal-body">
  {{ svg('path/to/spinner.svg')|attr({ class: 'loader' }) }}
</div>  
  
      .htmx-request .loader{
  display: block;
}

.loader{
  display: none;
}  

The htmx-request class will be appended to the modal-body element. Strictly speaking, the above CSS is not necessary, as the modal is hidden anyway, but these classes are helpful for different contexts when you might only want the spinner element to show when the component is being rerendered.

This is just a taste of what's possible with Sprig and Alpine. These two tools combined are very powerful and I can't wait to see what else I can create with them.