Cookbook

Sprig Cookbook Logo Sprig Cookbook

Ready-made component recipes.

Sprig allows you to create reactive components from Twig templates. Below are some recipes to help you learn some of the possibilities that Sprig provides. 

To learn more about Sprig, visit the Learning Resources.

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

🌿 Ingredients: s‑trigger, s‑replace.

🌱 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 ?? '' %}

{# Replaces only the `#results` div on keyup or when the value is changed #}
<input sprig s-trigger="keyup changed" s-replace="#results"
  type="text" name="query" value="{{ query }}" placeholder="Search">

<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‑val:*, s‑target, s‑swap.

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

Blitz

{# 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-val:*` attribute on the button #}
{% set offset = offset ?? 0 %}

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

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

{# If the total entry count is greater than the number that has been displayed #}
{% if entryQuery.count() > offset + entries|length %}
  {# Increments `offset` by the value of `limit` and swaps itself out on click #}
  <button sprig s-val:offset="{{ offset + limit }}" 
    s-target="this" s-swap="outerHTML">
    Load another
  </button>
{% endif %}

Load More Complex #

This component loads another entry each time the button is clicked, but has a slightly more complex element structure that needs to be maintained. Since we want to append only part of the re-rendered component, we use s-select to specify what to append, s-target to specify what element to append to, and s-swap to insert before the end of the target element (to append). We also use s-swap-oob to swap the button out-of-band (outside the target), so that the offset value is updated each time Load another” is clicked (note that an ID is required on the button for s-swap-oob to work).

🌿 Ingredients: s‑select, s‑target, s‑swap, s‑swap-oob.

🌱 Tip: the s-swap-oob allows you to specify that some content in a response should be swapped into the DOM somewhere other than the target, that is Out of Band”.

Blitz

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

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

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

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

<div id="entries" class="border">
  {% for entry in entries %}
    <h6>{{ entry.title }}</h6>
  {% endfor %}
</div>

{# If the total entry count is greater than the number that has been displayed #}
{% if entryQuery.count() > offset + entries|length %}
  {# Increments `offset` by the value of `limit` #}
  <button id="load-more-oob" sprig s-val:offset="{{ offset + limit }}"
    {# Appends all `#results h6` elements to the `#entries` element #}
    s-select="#entries h6" s-target="#entries" s-swap="beforeend"
    {# If this button was clicked then swap it out-of-band #}
    {{ sprig.trigger == 'load-more-oob' ? 's-swap-oob="true"' }}
  >
    Load another
  </button>
{% else %}
  {# Swaps the button out-of-band with a dummy button that is hidden #}
    <button id="load-more-oob" s-swap-oob="true" style="display: none"></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: sprig.paginate, s‑val:*, 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").

Blitz
Campaign
Sprig

Showing 1-3 of 16 entries.
Page 1 of 6 pages.

1 2 3 4 5 6

{# 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-val:*` on the clicked element #}
{% set page = page ?? 1 %}

{% set entryQuery = craft.entries.limit(limit) %}

{# Paginates the entry query given the current page #}
{% set pageInfo = sprig.paginate(entryQuery, page) %}
{% set entries = pageInfo.pageResults %}

{% 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-val:page="{{ page - 1 }}" s-push-url="?page={{ page - 1 }}">
      Previous
    </button>
  {% endif %}
  {% if page < pageInfo.totalPages %}
    {# Increments `page` by 1 and pushes the new value into the URL on click #}
    <button sprig s-val:page="{{ page + 1 }}" s-push-url="?page={{ page + 1 }}">
      Next
    </button>
  {% endif %}
  <p>
    <em>
      Showing {{ pageInfo.first }}-{{ pageInfo.last }} 
      of {{ pageInfo.total }} entries. 
    </em><br>
    <em>Page {{ pageInfo.currentPage }} of {{ pageInfo.totalPages }} pages.</em><br>
    {% for i in 1..pageInfo.totalPages %}
      {% if i == page %}
        {{ i }}
      {% else %}
        {# Refreshes the component and pushes the new value into the URL #}
        <a sprig s-val: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‑val:*.

🌱 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-val:section="plugins" 
  class="{{ section == 'plugins' ? 'active' }}">Plugins</button>
<button sprig s-val:section="articles"
  class="{{ section == 'articles' ? 'active' }}">Articles</button>
<button s-val: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 10:32:26 PM GMT+2


{{ 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>

Multi-Row Table Field #

This component provides a multi-row table field that can be used to submit any number of rows to a custom table field.

🌿 Ingredients: s‑val:*.

🌱 Tip: adding a row is done by merging a new default row, removing a row is done by filtering out the row by the provided key. 

{{ sprig('_components/multi-row-table-field') }}

{{ sprig.script }}
{#--- _components/multi-row-table-field ---#}

{# Sets the default row to contain 2 columns #}
{% set defaultRow = {col1: '', col2: ''} %}
{# Sets `rows` to the submitted value or the default row #}
{% set rows = fields.fieldHandle ?? [defaultRow] %}
{% if addRow is defined %}
  {% set rows = rows|merge([defaultRow]) %}
{% elseif removeRow is defined %}
  {% set rows = rows|filter((val, key) => removeRow != key) %}
{% endif %}

{% for row in rows %}
  <p>
    <input name="fields[fieldHandle][{{ loop.index0 }}][col1]" value="{{ row.col1 ?? '' }}" placeholder="Row {{ loop.index }} / Col 1">
    <input name="fields[fieldHandle][{{ loop.index0 }}][col2]" value="{{ row.col2 ?? '' }}" placeholder="Row {{ loop.index }} / Col 2">
    {# Outputs the remove button if more than one row exists #}
    {% if rows|length > 1 %}
      <a sprig s-val:remove-row="{{ loop.index0 }}" href="#" title="Remove row">
        <svg viewBox="0 0 20 20" fill="currentColor">
          <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM7 9a1 1 0 000 2h6a1 1 0 100-2H7z" />
        </svg>
      </a>
    {% endif %}
  </p>
{% endfor %}

<button sprig s-val:add-row="1">Add a row</button>

Nesting Components #

Components can be nested within other components.

🌿 Ingredients: s‑val:*.

🌱 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). 

Sendgrid
Entry Count
Blitz

{{ 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-val:*` 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-va: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 trigger the refresh event of the counter component using a single line of JavaScript in the second component.

🌿 Ingredients: component attributes, sprig.isRequest.

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

Liked: 0
Blitz

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

{# Sets the `_` attribute on the component #}
{{ sprig('_components/entry') }}

{{ sprig.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() %}

{% if sprig.isRequest %}
    <script>
        htmx.trigger('#counter', 'refresh');
    </script>
{% endif %}

<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 success variable in the response which is loaded into the component (as well as an errors variable on failure).

{{ 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 and success %}

  <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|first }}</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 success variable in the response which is loaded into the component (as well as an errors variable on failure).

{{ 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 and success %}

  <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|first }}</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 %}