Sprig Logo Sprig

A reactive Twig component framework for Craft.

Create reactive components from Twig templates and/or PHP classes.

Components can re-render themselves on user-triggered events.

Developer friendly, simple and infinitely extensible.

Sprig is a free plugin for Craft CMS that allows you to create reactive components from Twig templates and/​or PHP classes. These components can re-render themselves on user-triggered events (clicks, changes to input fields, form submissions, etc.) using AJAX requests, without requiring you to write a single line of JavaScript. 

Sprig enables common use-cases while completely avoiding full page refreshes:

  • Live searching
  • Loading more elements (with a button interaction or infinite scroll)
  • Pagination, ordering and filtering elements
  • Adding products to a cart
  • Submitting forms

View working examples in the Sprig cookbook and check out the learning resources.

Sprig 1.1.0 brings new features as well as some important security improvements. Read the details here.

Sprig

License #

This plugin is licensed for free under the MIT License.

Requirements #

Craft CMS 3.0.0 or later.

Installation #

To install the plugin, search for Sprig” in the Craft Plugin Store, or install manually using composer.

composer require putyourlightson/craft-sprig

Learning Resources #

Usage #

How it Works #

Sprig components are reactive Twig templates that contain relatively small amounts of code. They work similarly to included templates, however one major difference is that they must be able to exist independently of the parent template and the web request, since they can be re-rendered using an AJAX request at any time. 

We initialise a component using the sprig() function, passing in a template path as the first parameter (just like you might do with a regular include). We need to output the required sprig.script tag for Sprig to work, since it relies on JavaScript.

{#-- main.twig --#}

{# Creates a component from the template path #}
{{ sprig('_components/search') }}

{# Loads the required script from a CDN #}
{{ sprig.script }}

Inside our component template we’ll create a search form and results page (similar to that in the Craft docs).

{#-- _components/search.twig --#}

<form>
    <input type="text" name="query" value="">
    <input type="submit" value="Search">
</form>

To make the component reactive, we simply add the sprig attribute to any elements that should trigger a re-render. 

{# The `sprig` attribute makes the form re-render the component on submit #}
<form sprig>
    <input type="text" name="query" value="">
    <input type="submit" value="Search">
</form>

Now each time the form is submitted, the component will re-render itself. This happens in the background using an AJAX request to the server. The values of all input fields (including textarea and select fields) in the component will automatically become available as template variables.

This means that a variable called query will become available when the component is re-rendered. To ensure that the query variable is always available, it is good practice to set it to a fallback default value.

{# Sets to a default value if not defined #}
{% set query = query ?? '' %}

<form sprig>
    <input type="text" name="query" value="{{ query }}">
    <input type="submit" value="Search">
</form>

<div id="results">
    {# Outputs the result if `query` is not empty #}
    {% if query %}
        {% set entries = craft.entries().search(query).orderBy('score').all() %}
        {% for entry in entries %}
            {{ entry.title }}
       {% endfor %}
    {% endif %}
</div>

Search demo

No full-page requests were harmed in the making of this. View the live demo.

We can make the search input field reactive and get rid of the form and search button completely by adding the sprig attribute to the search field itself. The component will now re-render itself every time the change event of the search input field is triggered (the default trigger for input fields). We can also make it so that the re-render is triggered on keyup events provided the field value has changed, using the s-trigger attribute.

<input sprig s-trigger="keyup changed" type="text" name="query" value="{{ query }}">

Since we only really want to replace the search results (and not the search input field), we can target a specific element to swap the re-rendered component into using the s-target attribute. In this case we will target the inner content of the surrounding div with ID results. We’ll also want to output the search input field only when the component is included (on the first render), which we can do by checking that sprig.isInclude evaluates to true.

{% if sprig.isInclude %}
    <input sprig s-trigger="keyup changed" s-target="#results" type="text" name="query" value="{{ query }}">

    <div id="results"></div>
{% endif %}

{% if query %}
    {% set entries = craft.entries().search(query).orderBy('score').all() %}
    {% for entry in entries %}
        {{ entry.title }}
   {% endfor %}
{% endif %}

An easier way of achieving the same result is to use the s-replace attribute to specify which element to replace. The search input field will still be rendered in the component, but only the div with ID results will be replaced with the new version of itself.

<input sprig s-trigger="keyup changed" s-replace="#results" type="text" name="query" value="{{ query }}">

<div id="results">
    {% if query %}
        {% set entries = craft.entries().search(query).orderBy('score').all() %}
        {% for entry in entries %}
            {{ entry.title }}
        {% endfor %}
    {% endif %}
</div>

Search live demo

View the live demo.

Component Variables #

When creating a new component, you can pass it one or more variables that will become available in the template, as a hash in the second parameter.

{# Creates a component from the template path #}
{{ sprig('_components/search', {
    query: 'Wally',
}) }}

Only values that can be passed over HTTP requests may be used (strings, numbers and booleans). Arrays and elements cannot be used (use comma-separated strings and IDs instead).

Note that any variables passed into a Sprig component will be visible in the source code in plain text, so you should avoid passing in any sensitive data.

If you want to pass a variable into the component that cannot be modified or tampered with, prefix it with an underscore. It will still be readable but it will also be hashed so that it cannot be deliberately modified by users without an exception being thrown.

{# Creates a component with a protected variable #}
{{ sprig('_components/search', {
    _section: 'news',
}) }}

Request parameters (query string and body parameters) are automatically available in your components, provided they do not begin with an underscore.

{# The query string `?query=Wanda` is provided in the URL #}

Search results for “{{ query }}”

{# Outputs: Search results for “Wanda” #}

The following component will output entries, offset by and limited to the provided values (initially 0 and 1). Since the _limit variable is prefixed with an underscore, it cannot be tampered with.

{#-- main.twig --#}

{# Creates a component from the template path #}
{{ sprig('_components/load-more', {
    offset: 0,
    _limit: 1,
}) }}

{# Loads the required script from a CDN #}
{{ sprig.script }}
{#-- _components/load-more.twig --#}

{% set entries = craft.entries.offset(offset).limit(_limit).all() %}

{% for entry in entries %}
    <p>{{ entry.title }}</p>
{% endfor %}

{% if entries %}
    <div id="replace">
        <input type="hidden" name="offset" value="{{ offset + _limit }}">
        <button sprig s-target="#replace" s-swap="outerHTML">Load more</button>
    </div>
{% endif %}

We’ve used a div with an ID of replace, which we use as the target to load more entries into. In it, we put a hidden input field in which we can increment the offset by the limit value, as well as a load more button that targets its parent element. We set the s-swap attribute on the button to outerHTML to ensure that the entire div is replaced (by default only the inner HTML is). We’ve wrapped the div in a conditional so it will be output only if entries are found.

An alternative way of dynamically adding or modifying parameters in our components is to use the s‑vals attribute (it expects a JSON encoded list of name-value pairs). This differs from using an input field because it will be submitted with the request only when the button element is clicked.

<button sprig s-vals="{{ { offset: offset + _limit }|json_encode }}" s-target="this" s-swap="outerHTML">Load more</button>

The s-val:* attribute provides a more readable way of populating the s-vals attribute. Replace the * with a lower-case name (in kebab-case, dashes will be removed and the name will be converted to camelCase).

<button sprig s-val:offset="{{ offset + _limit }}" s-target="this" s-swap="outerHTML">Load more</button>

Load more demo

View the live demo.

Component Attributes #

When creating a new component, you can assign it one or more attributes, as a hash in the third parameter. Attributes beginning with s- can be used to apply logic to the component element itself.

{{ sprig('_components/search', {}, {
    'id': 'search-component',
    's-trigger': 'load, refresh',
}) }}

The example above makes it possible to trigger the component to refresh itself on load, as well as whenever we manually trigger a refresh event using JavaScript.

<script>
    document.getElementById('search-component').dispatchEvent(new Event('refresh'));
</script>

Components are assigned a trigger called refresh by default, which can be overridden using the s-trigger attribute as in the example above.

Actions #

We can call Craft as well as plugin/​module controller actions using the s-action attribute. Let’s take the example of an add to cart form (similar to that in the Commerce docs).

<form method="post">
    <input type="hidden" name="action" value="commerce/cart/update-cart">
    {{ csrfInput() }}
    <input type="hidden" name="purchasableId" value="{{ variant.id }}">
    <input type="submit" value="Add to cart">
</form>

To make this a reactive component, we’ll add the sprig attribute to the form, as well as the s-method and s-action attributes. Since this is a POST request , Sprig will take care of adding the CSRF token for us, so we can clean up our form as follows.

<form sprig s-method="post" s-action="commerce/cart/update-cart">
    <input type="hidden" name="purchasableId" value="{{ variant.id }}">
    <input type="submit" value="Add to cart">
</form>

Next, let’s replace the form with a an appropriate message on submission. The update-cart action will return a success value, as well as values for error and errors if there is a problem see this code block. Sprig will load those return values into template variables for us, so we can use them as follows.

{% if success is defined and success %}
    Product added to your cart!
{% else %}
    {% if error is defined %}
        <p class="error">{{ error }}</p>
    {% endif %}

    <form sprig s-method="post" s-action="commerce/cart/update-cart">
        <input type="hidden" name="purchasableId" value="{{ variant.id }}">
        <input type="submit" value="Add to cart">
    </form>
{% endif %}

Add to cart demo

Triggers #

Any HTML element can be made reactive by adding the sprig attribute to it inside of a component. By default, the natural” event of an element will be used as the trigger:

  • input, textarea and select elements are triggered on the change event.
  • form elements are triggered on the submit event.
  • All other elements are triggered on the click event.

If you want different behaviour you can use the s-trigger attribute to specify the trigger.

<div sprig s-trigger="mouseenter">
    Mouse over me to re-render the component.
</div>

If you want a trigger to only happen once, you can use the once modifier for the trigger.

<div sprig s-trigger="mouseenter once">
    Mouse over me to re-render the component only once.
</div>

View all of the available trigger options.

Component Classes #

In the examples above, we passed a template path into the sprig() function, which created a component directly from that template. If you want to have more control over the component and be able to use PHP logic then you can create a Component class.

First, create a new folder called sprig/components in your project’s root directory. This is where your Component classes should be created. In order for our Component classes to be autoloaded, we need to add the following to the project’s composer.json file.

  "autoload": {
    "psr-4": {
      "sprig\\components\\": "sprig/components/"
    }
  },

Running composer dump will regenerate the optimized autoload files for us.

Let’s create a file called ContactForm.php for our base component.

<?php
namespace sprig\components;

use putyourlightson\sprig\base\Component;

class ContactForm extends Component
{
}

In most cases, you’ll want the component to render a template. This can be done by setting a protected property $_template to the template path. All of the public properties of the class will be automatically be available as variables in the template.

<?php
namespace sprig\components;

use putyourlightson\sprig\base\Component;

class ContactForm extends Component
{
    public $success;
    public $error;
    public $email;
    public $message;

    protected $_template = '_components/contact-form';

    public function send()
    {
        $this->success = SomeEmailApi::send([
            'email' => $this->email,
            'message' => $this->message,
        );

        if (!$this->success) {
            $this->error = SomeEmailApi::getError();
        }
    }
}

We added a send action as a public method in our class which we can call using the s-action attribute.

{#-- _components/contact-form --#}

{% if success %}
    Thank you for getting in touch!
{% else %}
    {% if error %}
        <p class="error">{{ error }}</p>
    {% endif %}

    <form sprig s-action="send">
        <input type="email" name="email" value="{{ email }}">
        <input type="text" name="message" value="{{ message }}">
    </form>
{% endif %}

If you prefer then you can override the render method which will be called each time the component is rendered.

public function render(): string
{
    return 'Contact us by email at [email protected]';
}

Now we can create the component from our ContactForm class as follows, passing in any variables as before.

{#-- main.twig --#}

{# Creates a component from the ContactForm class  #}
{{ sprig('ContactForm', {
    message: 'Say hello',
}) }}

Contact form demo

View the live demo.

Security Considerations #

Any variables passed into a Sprig component will be visible in the source code in plain text, so you should avoid passing in any sensitive data. Variables can also be overridden (unless prefixed it with an underscore) by manipulating the query string and source code, so you should validate that a variable value is allowed if it could lead to revealing sensitive data.

{% set section = section ?? '' %}
{% set allowedSections = ['articles', 'plugins'] %}

{% if section not in allowedSections %}
    This section is not allowed.
{% else %}
    {% set entries = craft.entries.section(section).all() %}
%}

The code above will only fetch the entries in the provided section if it is either articles or plugins. This avoids the possibility that someone could change section to secrets and gain access to entries that should not be visible. See an example using protected variables here.

Since variables can contain user input, you should be aware of the risk of introducing a Cross-Site Scripting (XSS) vulnerability into your code. Variables output by Twig are automatically escaped, but you should always ensure that your HTML attribute values are wrapped in quotes and that you escape any variables that are output inside of script tags using the JavaScript escape filter.

{# It is unsafe to omit quotes, do NOT do this. #}
<div s-val:name={{ name }}>

{# It is safe to use either double or single quotes. #}
<div s-val:name="{{ name }}">
<div s-val:name='{{ name }}'>

<script>
    {# Escape for JavaScript. #}
    console.log('{{ name|escape('js') }}');
</script>

Live Demos #

  1. Search
  2. Search Live
  3. Load More
  4. Contact Form Component

For copy-paste examples of using Sprig with Craft, check out the Sprig Cookbook.

Playground #

Sprig comes with a playground that is available in the control panel. The playground allows you to quickly experiment with a default Sprig component, as well as create and save your own. The playground can be disabled on a per environment basis using the enablePlayground config setting.

Sprig playground

Htmx #

Sprig requires and uses htmx (~9 KB gzipped) under the hood, although it tries to remain as decoupled as possible by not providing any JavaScript code of its own.

Listen to dev​Mode​.fm podcast on htmx.

Anything you can do with hx- attributes you can also do with s- and sprig- attributes. See the full attribute reference.

You can load htmx directly from a CDN using the {{ sprig.script }} tag. This is the recommended way because Sprig can then select the appropriate version of htmx.

{# Loads htmx from a CDN #}
{{ sprig.script }}

If you prefer to install the package using npm then be sure to install the same version that the plugin uses.

npm install htmx.org

JavaScript #

As of version 1.1.0, <script> tags containing JavaScript code in your components will be executed when a component is re-rendered.

<script>
   console.log('I was just re-rendered.');
</script>

Be careful when outputting variables that could include user input inside script tags as this can lead to XSS vulnerabilities (see security considerations). Ideally you should escape all variables using the |escape('js') (or e|('js') filter.

<script>
   console.log('{{ message|escape('js') }});
</script>

Alternatively, you can use a htmx event such as htmx:afterSwap to run a function after a component is re-rendered and swapped into the DOM. The following code should exist in or be called from the parent template.

<script>
   htmx.on('htmx:afterSwap', function(event) {
       // do something
   });
</script>

If using a JavaScript library such as AlpineJS, you should avoid using invalid HTML attributes in your components such as @click, opting instead for the valid, more verbose form x-on:click. You may need to force AlpineJS to rescan the DOM after a component is re-rendered which you can do with Alpine.start() or one of the other methods described here.

Attributes #

The following attributes are specific to Sprig.

s-action #

Sends an action request to the provided controller action.

<form sprig s-action="plugin-handle/controller/action">

s-method #

Forces the request to be of the type provided. Possible values are get (default) or post. If set to post, Sprig automatically sends a CSRF token in the request.

<form sprig s-method="post">

Inherited from htmx #

The following attributes are commonly used in Sprig and map directly to hx- attribute equivalents in htmx. See the full attribute reference.

s-confirm #

Shows a confim() dialog before issuing a request (reference).

<button sprig s-confirm="Are you sure you wish to delete this entry?">Delete</button>

s-include #

Includes additional element values in AJAX requests (reference).

s-indicator #

The element to put the htmx-request class on during the AJAX request (reference).

s-params #

Filters the parameters that will be submitted with a request (reference).

s-prompt #

Shows a prompt before submitting a request (reference).

<button sprig s-prompt="Enter the slug of this entry to confirm deletion.">Delete</button>

s-push-url #

Pushes a URL into the URL bar and creates a new history entry (reference).

s-replace #

Specifies the element to be replaced in the component. This is equivalent to combining s-select, s-target and s-swap.

<input name="query" sprig s-replace="#results">

Equivalent to:

<input name="query" sprig s-select="#results" s-target="#results" s-swap="outerHTML">

s-select #

Selects a subset of the server response to process (reference).

s-swap #

Controls how the response content is swapped into the DOM (e.g. outerHTML or beforeEnd) (reference).

<input name="query" sprig s-swap="outerHTML" s-target="#results">

s-swap-oob #

Marks content in a response as being Out of Band”, i.e. swapped somewhere other than the target (reference).

s-target #

Specifies the target element to be swapped (reference).

<input name="query" sprig s-target="#results">

s-trigger #

Specifies the event that triggers the request (reference).

<input name="query" sprig s-trigger="keyup changed">

s-vals #

Adds to the parameters that will be submitted with the request (reference). The value must be a JSON encoded list of name-value pairs.

<button sprig s-vals="{{ { page: page + 1, limit: 10 }|json_encode }}">Next</button>

s-val:* #

Provides a more readable way of populating the s-vals attribute. Replace the * with a lower-case name (in kebab-case, dashes will be removed and the name will be converted to camelCase).

<button sprig s-val:page="{{ page + 1 }}" s-val:limit="10">Next</button>

s-vars #

The s-vars attribute has been deprecated for security reasons. Use use the more secure s‑vals or s‑val:* instead.

The expressions in s-vars are dynamically computed which allows you to add JavaScript code that will be executed. While this is a powerful feature, it can lead to a Cross-Site Scripting (XSS) vulnerability if user input is included in expressions. If you absolutely require dynamically computed expressions then use hx-vars directly.

Adds to the parameters that will be submitted with the request (reference). The value must be a comma-separated list of name-value pairs.

<button sprig s-vars="page: {{ page + 1 }}, limit: 10">Next</button>

Template Variables #

Sprig provides the following template variables.

sprig.script #

Outputs a script tag that loads htmx directly from a CDN (or locally if Craft is running in a dev environment). This is the recommended way of loading htmx because Sprig can select the appropriate version.

{{ sprig.script }}

sprig.paginate(elementQuery) #

Paginates an element query and returns a variable that contains the current page results and information.

The sprig.paginate method accepts an element query and a page number (defaults to 1) and returns a variable that provides the following properties and methods:

  • pageInfo.pageResults – The element results for the current page.
  • pageInfo.first – The offset of the first element on the current page.
  • pageInfo.last – The offset of the last element on the current page.
  • pageInfo.total – The total number of elements across all pages
  • pageInfo.currentPage – The current page number.
  • pageInfo.totalPages – The total number of pages.
  • pageInfo.getRange(start, end) – Returns a range of page numbers as an array.
  • pageInfo.getDynamicRange(max) – Returns a dynamic range of page numbers that surround (and include) the current page as an array.
{% set entryQuery = craft.entries.limit(10) %}
{% set pageInfo = sprig.paginate(entryQuery, page) %}

{% set entries = pageInfo.pageResults %}

Showing {{ pageInfo.first }} to {{ pageInfo.last }} of {{ pageInfo.total }} entries.
Showing page {{ pageInfo.currentPage }} of {{ pageInfo.totalPages }}.

sprig.pushUrl(url) #

Pushes the URL into the URL bar and creates a new history entry.

{% do sprig.pushUrl('?page=' ~ page) %}

sprig.redirect(url) #

Forces the browser to redirect to the URL.

{% do sprig.redirect('/new/page') %}

sprig.refresh() #

Forces the browser to refresh the page.

{% do sprig.refresh() %}

sprig.triggerEvents(events) #

Triggers one or more client-side events. The events parameter can be a string or an array.

{% do sprig.triggerEvents('eventA, eventB') %}

{% do sprig.triggerEvents(['eventA', 'eventB']) %}

sprig.isInclude #

Returns true if the template is being rendered on initial page load (not through an AJAX request), otherwise, false.

{% if sprig.isInclude %}
    Template rendered on initial page load
{% else %}
    Template rendered through an AJAX request
{% endif %}

sprig.isRequest #

Returns true if the template is being rendered through an AJAX request (not on initial page load), otherwise, false.

{% if sprig.isRequest %}
    Template rendered through an AJAX request
{% else %}
    Template rendered on initial page load
{% endif %}

sprig.element #

Returns the ID of the active element, if it exists.

sprig.elementName #

Returns the name of the active element, if it exists.

sprig.elementValue #

Returns the value of the active element, if it exists.

sprig.eventTarget #

Returns the ID of the original target of the event that triggered the request.

sprig.prompt #

Returns the value entered by the user when prompted via s-prompt.

sprig.target #

Returns the ID of the target element.

sprig.trigger #

Returns the ID of the element that triggered the request.

sprig.triggerName #

Returns the name of the element that triggered the request.

sprig.url #

Returns the URL that the Sprig component was loaded from.

Acknowledgements #

This plugin stands on the shoulders of giants.

Special thanks to Andrew Welch, John D. Wells and Keyur Shah for being a sounding board and a source of valuable input. Thanks also goes out to Z (you know who you are).