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

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 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-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 11:39:38 AM GMT+1


{{ 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‑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). 

Campaign
Sendgrid
Dashboard Begone

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