Sprig Logo Sprig Cookbook

Copy-paste recipes for Craft sites.

Sprig allows you to create reactive components from Twig templates. Below are some recipes to get you up and running with Sprig. 

To learn more, read the Sprig docs, listen to the dev​Mode​.fm podcast or watch the CraftQuest livestream.

This component refreshes the results whenever the input field detects a keyup that changes its value. 

🌿 Ingredients: s‑trigger, s‑target, s‑swap.

🌱 Tip: start by typing s”.

{{ sprig('_components/search') }}

{{ sprig.script }}
{#--- _components/search ---#}

{# Sets a default value if not defined by the `query` input field below #}
{% set query = query ?? '' %}

{# Outputs the search field only on initial page load (not in AJAX requests) #}
{% if sprig.isInclude %}
  {# Updates the entire `#results` div on keyup or when the value is changed #}
  <input sprig s-trigger="keyup changed" s-target="#results" s-swap="outerHTML" 
    type="text" name="query" value="{{ query }}" placeholder="Search">
{% endif %}

{# This is always output and is the target of the swap #}
<div id="results">
  {% if query %}
    {% set entries = craft.entries.search(query).all() %}
    {% if entries|length %}
      {% for entry in entries %}
        <a href="{{ entry.url }}">{{ entry.title }}</a>
      {% endfor %}
    {% else %}
        No results
    {% endif %}
  {% endif %}
</div>

Load More #

This component loads another entry each time the button is clicked.

🌿 Ingredients: s‑vars, s‑target, s‑swap.

🌱 Tip: can you figure out what will happen when all entries are loaded?

Sprig

{# Passes a variable called `limit` into the component #}
{{ sprig('_components/load-more', {'limit': 1}) }}

{{ sprig.script }}
{#--- _components/load-more ---#}

{# Sets a default value if not defined by the `s-vars` attribute on the button #}
{% set offset = offset ?? 0 %}

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

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

{% if entries %}
  {# Increments `offset` by the value of `limit` and swaps itself out on click #}
  <button sprig s-vars="offset: {{ offset + limit }}" 
    s-target="this" s-swap="outerHTML">
    Load another
  </button>
{% endif %}

Pagination #

This component loads the previous or next entry depending on which button is clicked. It keeps the query string in the URL updated and even remembers the page number when the browser window is refreshed.

🌿 Ingredients: s‑vars, s‑push-url.

🌱 Tip: use the show:top modifier to show the top of the component after re-rendering the component (s-swap="innerHTML show:top").

Sprig
Blitz
Campaign

Showing 1-3 of 15 entries.
Page 1 of 5 pages.

1 2 3 4 5

{# Passes a variable called `limit` into the component #}
{{ sprig('_components/pagination', {'limit': 3}) }}

{{ sprig.script }}
{#--- _components/pagination ---#}

{# Sets a default value if not defined by `s-vars` on the clicked element #}
{% set page = page ?? 1 %}

{# Sets the number of entries to offset the query by #}
{% set offset = (page - 1) * limit %}

{% set query = craft.entries.offset(offset).limit(limit) %}
{% set entries = query.all() %}
{% set totalEntries = query.count() %}
{% set totalPages = (totalEntries / limit)|round(0, 'ceil') %}

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

{% if entries %}
  {% if page > 1 %}
    {# Decrements `page` by 1 and pushes the new value into the URL on click #}
    <button sprig s-vars="page: {{ page - 1 }}" s-push-url="?page={{ page - 1 }}">
      Previous
    </button>
  {% endif %}
  {% if page < totalPages %}
    {# Increments `page` by 1 and pushes the new value into the URL on click #}
    <button sprig s-vars="page: {{ page + 1 }}" s-push-url="?page={{ page + 1 }}">
      Next
    </button>
  {% endif %}
  <p>
    <em>
      Showing {{ offset + 1 }}-{{ offset + entries|length }} 
      of {{ totalEntries }} entries.
    </em><br>
    <em>Page {{ page }} of {{ totalPages }} pages.</em><br>
    {% for i in 1..totalPages %}
      {% if i == page %}
        {{ i }}
      {% else %}
        {# Refreshes the component and pushes the new value into the URL #}
        <a sprig s-vars="page: {{ i }}" s-push-url="?page={{ i }}">{{ i }}</a>
      {% endif %}
    {% endfor %}
  </p>
{% endif %}

Protected Variables #

This component outputs the entries in one of a protected set of sections. Since the _allowedSections variable begins with an underscore, it cannot be tampered with.

🌿 Ingredients: s‑vars.

🌱 Tip: inspect the component source code (in your browser’s developer tools) to see how protected variables are hashed to prevent tampering. 

A Technical Rundown of How Project Config Works
Add-on Support Visualised
All Add-on Income Donated On 13.12.11
Amazon SES
Another Year, Another Redesign

{# Passes a protected variable `_allowedSections` into the component #}
{{ sprig('_components/protected-vars', {'_allowedSections': 'plugins, articles'}) }}

{{ sprig.script }}
{#--- _components/protected-vars ---#}

{# Defaults to the value of `_allowedSections` if `section` is not defined #}
{% set section = section ?? _allowedSections %}

{# Each button defines a `section` and refreshes the component on click #}
<button sprig 
  class="{{ section == _allowedSections ? 'active' }}">All</button>
<button sprig s-vars="section: 'plugins'" 
  class="{{ section == 'plugins' ? 'active' }}">Plugins</button>
<button sprig s-vars="section: 'articles'" 
  class="{{ section == 'articles' ? 'active' }}">Articles</button>
<button sprig s-vars="section: 'secrets'" 
  class="{{ section == 'secrets' ? 'active' }}">Secrets</button>

{# Only outputs entries in `section` if it exists in `_allowedSections` #}
{% if section in _allowedSections %}

  {% set entries = craft.entries.section(section).limit(5).all() %}

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

{% else %}

  <p class="error">The section “{{ section }}” is not in the allowed sections.</p>

{% endif %}

Dynamic Content #

This component loads random entries after the page has loaded. This is useful if you want to save upfront database calls or when using static page caching with Blitz or another method.

🌿 Ingredients: s‑trigger, sprig.isRequest.

🌱 Tip: refresh the page to see the content get dynamically injected on page load and explore all the available triggers.

{# Passes in a variable and sets the `s-trigger` attribute on the component #}
{{ sprig('_components/dynamic-content', {'limit': 3}, {'s-trigger': 'load'}) }}

{{ sprig.script }}
{#--- _components/dynamic-content ---#}

{# Outputs the entries only if an AJAX request (not initial page load) #}
{% if sprig.isRequest %}
  {% set entries = craft.entries.orderBy('RAND()').limit(limit).all() %}

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

Polling #

This component polls for the current time every 5 seconds or when the timezone is changed (the default trigger for a select element).

🌿 Ingredients: s‑trigger.

🌱 Tip: explore all the available triggers.

The time is 18:45:13 CEST


{{ sprig('_components/polling') }}

{{ sprig.script }}
{#--- _components/polling ---#}

{# Sets a default value if not defined by the `timezone` select field below #}
{% set timezone = timezone ?? 'Europe/Vienna' %}

{# Refreshes the component every 5 seconds #}
<div sprig s-trigger="every 5s" class="pulse">
  The time is {{ now|time('long', timezone=timezone) }}
</div>

{# Refreshes the component with a new value for `timezone` on change #}
<select sprig name="timezone">
  <option value="US/Pacific" {{ timezone == 'US/Pacific' ? 'selected' }}>US/Pacific</option>
  <option value="America/Buenos_Aires" {{ timezone == 'America/Buenos_Aires' ? 'selected' }}>America/Buenos_Aires</option>
  <option value="Europe/Vienna" {{ timezone == 'Europe/Vienna' ? 'selected' }}>Europe/Vienna</option>
  <option value="Asia/Hong_Kong" {{ timezone == 'Asia/Hong_Kong' ? 'selected' }}>Asia/Hong_Kong</option>
  <option value="Australia/Sydney" {{ timezone == 'Australia/Sydney' ? 'selected' }}>Australia/Sydney</option>
</select>

Indicator #

This component confirms the action and displays a loading indicator while a long-running request is pending.

🌿 Ingredients: s‑indicator, s‑confirm, sprig.isRequest.

🌱 Tip: overriding htmx.config.requestClass is optional and would otherwise default to htmx-request.

{{ sprig('_components/indicator') }}

{{ sprig.script }}

{# Overrides the request class (default is `htmx-request`) #}
<script>
  htmx.config.requestClass = 'loading';
</script>

{# Adds CSS styling to the loading indicator #}
<style>
  #indicator .loader {
    display: none;
  }
  #indicator.loading .loader {
    display: inline;
  }
  #indicator.loading .complete {
    display: none;
  }
</style>
{#--- _components/indicator ---#}

{# Runs the long-running process only if an AJAX request (not initial page load) #}
{% if sprig.isRequest %}
  {# Run long-running process #}
{% endif %}

{# Confirms the action and adds the `requestClass` class to `#indicator` #}
<button sprig s-confirm="Are you sure? This process will run for a few seconds."
  s-indicator="#indicator">
  Start a long-running process
</button>

<div id="indicator">
  {# This image is hidden by our CSS styles above in certain cases #}
  <img class="loader" src="/src/img/loader.gif">

  {# This image is output only if an AJAX request (not initial page load) #}
  {% if sprig.isRequest %}
    <img class="complete" src="/src/img/tick.svg">
  {% endif %}
</div>

Nesting Components #

Components can be nested within other components.

🌿 Ingredients: s‑vars.

🌱 Tip: notice how we get the entry IDs using the ids() function on the element query and pass the entry ID into the child component, as only values that can be passed over HTTP requests may be used (strings, numbers, booleans). 

Blitz
Sprig
Entry Count

{{ sprig('_components/nesting-parent') }}

{{ sprig.script }}
{#--- _components/nesting-parent ---#}

<div class="border">

  {# Loops over the IDs of randomly selected entries #}
  {% for entryId in craft.entries.orderBy('RAND()').limit(3).ids() %}
    {# Passes a variable called `entryId` into the child component #}
    {{ sprig('_components/nesting-child', {'entryId': entryId}) }}
  {% endfor %}

  {# Refreshes the parent component on click #}
  <button sprig>Refresh Random Entries</button>

</div>
{#--- _components/nesting-child ---#}

{# Sets a default value if not defined by the `s-vars` attribute on the link #}
{% set liked = liked ?? false %}

{% set entry = craft.entries.id(entryId).one() %}

<h6 class="border">
  {{ entry.title }}
  {% if liked %}
    <img src="/src/img/heart-red.svg">
  {% else %}
    {# Sets the value of `liked` and refreshes the component on click #}
    <a sprig s-vars="liked: 1" href="#">
      <img src="/src/img/heart-grey.svg">
    </a>
  {% endif %}
</h6>

Refreshing Components #

Components can trigger refreshes of other components. In the recipe below, we use the third parameter of the sprig function to assign attributes to the components. We add an id of counter to the first component and some hyperscript (a companion project of htmx) that triggers the refresh event of the counter component to the second component.

🌿 Ingredients: component attributes.

🌱 Tip: all components have a refresh event assigned to them by default.

Liked: 0
Sprig

{# Sets the `id` attribute on the component #}
{{ sprig('_components/counter', {}, {'id': 'counter'}) }}

{# Sets the `_` attribute on the component #}
{{ sprig('_components/entry', {}, {
  '_': 'on htmx:afterSwap send refresh to #counter'
}) }}

{{ sprig.script }}

{# Includes hyperscript in the page #}
{{ sprig.hyperscript }}

{# Listens for events with JavaScript as an alternative to hyperscript #}
<script>
  // htmx.on('htmx:afterSwap', function(event) {
  //     document.getElementById('counter').dispatchEvent(new Event('refresh'));
  // });
</script>
{#--- _components/counter ---#}

{# Sets the value to `1` if this is an AJAX request (not initial page load) #}
Liked: {{ sprig.isRequest ? 1 : 0 }}
{#--- _components/entry ---#}

{% set entry = craft.entries.one() %}

<h6>
  {{ entry.title }}
  
  {# If this is an AJAX request (not initial page load) #}
  {% if sprig.isRequest %}
    <img src="/src/img/heart-red.svg">
  {% else %}
    {# Refreshes the component on click #}
    <a sprig href="#">
      <img src="/src/img/heart-grey.svg">
    </a>
  {% endif %}
</h6>

Wishlist #

This component displays an add/​remove button, depending on whether an entry is in a list, using the Wishlist plugin.

🌿 Ingredients: s‑trigger.

🌱 Tip: notice how we pass an entry ID into the component and not a wishlist item, as only values that can be passed over HTTP requests may be used (strings, numbers, booleans).

{# Passes a variable called `entryId` into the component #}
{{ sprig('_components/wishlist', {'entryId': entry.id}) }}

{{ sprig.script }}
{#--- _components/wishlist ---#}

{# The value of this input field will be posted to the action #}
<input type="hidden" name="elementId" value="{{ entryId }}">

{% set item = craft.wishlist.item(entryId) %}

{% if item.inList %}
  {# Posts to the `remove` action on click #}
  <button sprig s-method="post" s-action="wishlist/items/remove">
    Remove from wishlist
  </button>
  <img src="/src/img/heart-red.svg">
{% else %}
  {# Posts to the `add` action on click #}
  <button sprig s-method="post" s-action="wishlist/items/add">
    Add to wishlist
  </button>
  <img src="/src/img/heart-grey.svg">
{% endif %}

Entry Form #

This component submits an entry form with back-end validation.

🌿 Ingredients: s‑method, s‑action.

🌱 Tip: the entries controller returns a JSON response which is loaded into the component.

{{ sprig('_components/entry-form') }}

{{ sprig.script }}
{#--- _components/entry-form ---#}

{# Sets a default value if not defined by the `title` input field below #}
{% set title = title ?? '' %}

{# The `success` variable is defined by the entries controller on success #}
{% if success is defined %}

  <p>The entry "{{ title }}" was successfully created!</p>
  <button sprig>Start Over</button>

{% else %}

  {# The `errors` variable is defined by the entries controller on failure #}
  {% if errors is defined %}
    {% for error in errors %}
      <p class="error">{{ error|join(', ') }}</p>
    {% endfor %}
  {% endif %}

  {# Posts to the `save-entry` action on submit #}
  <form sprig s-method="post" s-action="entries/save-entry">
    <input type="hidden" name="sectionId" value="1">
    <input type="text" name="title" value="{{ title }}" placeholder="Title">
    <input type="submit" value="Create Entry">
  </form>

{% endif %}

Contact Form #

This component submits a contact form with back-end validation.

🌿 Ingredients: s‑method, s‑action.

🌱 Tip: the send controller returns a JSON response which is loaded into the component.

{{ sprig('_components/contact-form') }}

{{ sprig.script }}
{#--- _components/contact-form ---#}

{# Sets default values if not defined by the input fields below #}
{% set fromName = fromName ?? '' %}
{% set fromEmail = fromEmail ?? '' %}
{% set message = message ?? '' %}

{# The `success` variable is defined by the contact form controller on success #}
{% if success is defined %}

  <p>Your message was successfully sent!</p>

{% else %}

  {# The `errors` variable is defined by the contact form controller on failure #}
  {% if errors is defined %}
    {% for error in errors %}
      <p class="error">{{ error|join(', ') }}</p>
    {% endfor %}
  {% endif %}

  {# Posts to the `send` action on submit #}
  <form sprig s-method="post" s-action="contact-form/send">
    <input type="text" name="fromName" value="{{ fromName }}" placeholder="Name">
    <input type="email" name="fromEmail" value="{{ fromEmail }}" placeholder="Email">
    <textarea name="message" placeholder="Message">{{ message }}</textarea>
    <input type="submit" value="Send Message">
  </form>

{% endif %}