Cookbook pot sheep 01

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. 

Live Sprig Training is now available!!

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') }}
{#--- _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 delay:200ms" 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}) }}
{#--- _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}) }}
{#--- _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
Sherlock

Showing 1-3 of 22 entries.
Page 1 of 8 pages.

1 2 3 4 5 6 7 8

{# Passes a variable called `limit` into the component #}
{{ sprig('_components/pagination', {'limit': 3}) }}
{#--- _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. 

Critical Update for a Blitz Blunder
Vienna Craft CMS Meetup 2024
Sprig đź–¤ htmx 2
Complying with Gmail & Yahoo's Email Sending Requirements
Should you be using Craft 5?

{# Passes a protected variable `_allowedSections` into the component #}
{{ sprig('_components/protected-vars', {'_allowedSections': 'plugins, articles'}) }}
{#--- _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.

Watch the CraftQuest livestream on how to use Sprig to inject dynamic content into statically-cached pages.

{# Passes in a variable and sets the `s-trigger` attribute on the component #}
{{ sprig('_components/dynamic-content', {'limit': 3}, {'s-trigger': 'load'}) }}
{#--- _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 14:46:01 CET


{{ sprig('_components/polling') }}
{#--- _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.setConfig, sprig.isRequest.

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

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

{# Overrides the request class (default is `htmx-request`) #}
{% do sprig.setConfig({ requestClass: 'loading' }) %}

{# 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') }}
{#--- _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, although this should be used sparingly and avoided whenever possible, as nesting components adds complexity and, in some cases challenges, to component re-rendering.

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

Datastar
Cache Igniter
Blitz

{{ sprig('_components/nesting-parent') }}
{#--- _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-val:liked="1" href="#">
      <img src="/src/img/heart-grey.svg">
    </a>
  {% endif %}
</h6>

Refreshing Components #

A component can listen for refresh events on one or more other components and proceed to refresh itself. In the recipe below, we use the third parameter of the sprig function to assign attributes to the components. We add an id of entry to the second component and set the s-listen attribute of the first component so that it listens for when the second component is refreshed. In other words, the counter component will be refreshed each time the entry component is refreshed.

🌿 Ingredients: component attributes, s‑listen, sprig.isRequest.

🌱 Tip: the s-listen attribute accepts one or more CSS selectors, separated by commas.

Liked: 0
Entry

{# This component is refreshed each time the `#entry` component is refreshed. #}
{{ sprig('_components/counter', {}, {'s-listen': '#entry'}) }}

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

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

<h6>
  Entry
  {# 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>

Alternatively, a component can trigger a refresh of one or more other components. In the recipe below, we add an id of counter to the first component and trigger a refresh of the counter component using sprig.triggerRefresh in the second component.

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

Liked: 0
Entry

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

{{ sprig('_components/entry') }}
{#--- _components/counter ---#}

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

{% if sprig.isRequest %}
    {% do sprig.triggerRefresh('#counter') %}
{% endif %}

<h6>
  Entry
  {# 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‑method, s‑action.

🌱 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}) }}
{#--- _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 results in either a success or an error, which can be tested using sprig.isSuccess and sprig.isError respectively. It also includes an errors variable on failure.

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

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

{% if sprig.isSuccess %}
  <p>The entry "{{ title }}" was successfully created!</p>
  <button sprig>Start Over</button>
{% elseif sprig.isError %}
  {# 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 %}
{% else %}
  {# 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 results in either a success or an error, which can be tested using sprig.isSuccess and sprig.isError respectively. It also includes an errors variable on failure.

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

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

{% if sprig.isSuccess %}
  <p>Your message was successfully sent!</p>
{% elseif sprig.isError %}
  {# 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 %}
{% else %}
  {# 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 %}

Product Variant Switcher #

This component provides a product variant switcher for Craft Commerce.

🌿 Ingredients: s‑method, s‑action, s‑val:*.

🌱 Tip: this recipe is a simplified version of the component found in the Building reactive Craft Commerce product page with Sprig plugin article.

A New Toga

Selected Variant: toga-default - $20

{{ sprig('_components/product-variant-switcher', { productId: product.id }) }}
{#--- _components/product-variant-switcher ---#}

{% set variantId = variantId ?? null %}

{% set product = craft.products.id(productId).one() %}
{% set selectedVariant = product.defaultVariant %}
{% set productVariants = product.variants %}

{% for productVariant in productVariants %}
  {% if productVariant.id == variantId %}
    {% set selectedVariant = productVariant %}
  {% endif %}
{% endfor %}

<h6>{{ product.title }}</h6>

Selected Variant: 
{{ selectedVariant.sku }} - {{ selectedVariant.price|commerceCurrency }}

<select sprig name="variantId">
  {% for productVariant in productVariants %}
    <option value="{{ productVariant.id }}" 
      {{ productVariant.id == selectedVariant.id ? 'selected' }}
    >
      {{ productVariant.sku }}
    </option>
  {% endfor %}
</select>

<button sprig s-method="post" s-action="commerce/cart/update-cart" 
  s-val:purchasable-id="{{ selectedVariant.id }}"
>
  Add to Cart
</button>

{% if success is defined %}
  {{ success ? flashes.notice : flashes.error }}
{% endif %}