← Back to Archive

Selected Samples Through Cart & Checkout


Providing sample selections in Cart, and then displaying them in Checkout


The overview of the Sample Selector solution, live in production.

Lately, I've been doubling down on building universally reusable components that I can literally plug-and-play into any proof of concept environment, at any time. One of the most commonly requested customisations that I'm asked about is offering Sample Products. This is especially important for skincare and beauty brands, or in the luxury space where the average order value is high, and giving away some free stuff is a nice way to keep those good times rolling.

If you're just here to grab the sample code and test it out in your own theme, scroll down! No judgements.

It's not always apparent as to the best possible solution for providing extra items in an order. You could offer them at Checkout in a UI extension, but then you risk slowing down the purchasing process and confusing the customer as to what's free and what's part of their order. Also, most likely, sample products are an entirely different SKU from a sellable product - they may not have inventory counts or a price, so they don't really need to interact with Checkout much at all. (For the purposes of this sample solution, I'm considering that they don't have any impact on shipping weights or costs.)

So, the best place to put a samples selector is at the Cart, in my opinion. We can build a cart object to inform the checkout, which is a nice way to learn the methodology that unlocks a lot of additonal solution pathways -- setting 'cart attributes' to inform a UI Extension or Function at Checkout. I've written before about handling this process at the checkout stage, but this time we'll be doing it from the cart stage.

Building the liquid section for Cart

A cart section that allows for free samples selection.

The short GIF above gives a pretty good idea of the configurability that I wanted to bring into the resuable demo right from the start. Nothing is hardcoded - once I add the section to my theme, I can change all the placeholder text, change the dynamic collection that's pulling in the products for my samples display, and even change the maximum amount of samples a customer can select.

Let's back up a second, and define a liquid 'section', so we have a baseline of what's going on in the example.

Sections are resuable components that act as the building blocks of all modern Shopify themes. You build them once, and then in the Theme Editor, you can move them around anywhere you like - across pages and templates, at will. They'll often have configurable settings built within them, so that you can modify the look and feel of the section without needed to touch the underlying code.

You can see here that the schema that I'm defining in the Section code directly correlates to the settings that are surfaced within the Theme Editor, making it really easy to use variables across the code that are informed by choices the user makes during their own configuration.

A view of the settings in the Theme Editor, alongside it's code.

I'll provide the full liquid Section code at the end of this post, which is effectively an entire demo solution packaged into one file. It contains it's own JS, CSS, as well as the Liquid code that underpins all of the functionality. That's not entirely best practice, because it means it's effectively isolated from the rest of the theme files -- so if I make an overall store design change, the custom Section might not inherit all of it. For the purposes of an evergreen piece of code though, it works pretty well. I've tested it across a few different themes.

If you want to modify the look and feel of the section, all you need to do is add your own custom CSS directly within Shopify's Theme Editor within the Section itself, like this:

Updating custom CSS example.

Back to the more techy bit - the Section pulls products from a dynamic collection source and surfaces them for a user to select. Once they've made their selection, this is where the cool part starts to happen. In the background, the Section calls the cart/update.js endpoint to set the selected Product ID as a cart attribute. This enables a few key things immediately:

  • The user's active cart session holds their selected sample products in local storage, so if they leave the page or come back to their cart later, their selections are retained.
  • We can reference the set cart attributes later in the Checkout to render the selections in a UI Extension. Just a nice thing to do.
  • The attributes as set as cart 'notes', which will be visible within order details after the checkout is completed.

A side note: the attributes I'm setting are private properties, meaning they should never be visible to a customer. In theory, a savvy customer could view them by reviewing the local storage in their browser's developer tools. Just be aware that it's possible, however rare.

Why not set them as cart metafields? Good question, thanks for asking. Because this is a Liquid solution, we don't have ready access to the Storefront API to hit the cart metafields endpoint. In a headless framework, I'd consider it. Although a consideration is that metafields are cached, which for a pretty rapid user selection process like this one, where they can change their mind readily, we could hit some update issues.

In the process of building this solution, I actually built a little side-tool to debug cart attributes, so that you can ensure they're being set correctly at the Cart, and properly being passed into Checkout. You can find this tool here, in my sample Checkout Extension app. (Could you add logging for the console to output? Yes. But this is cooler.)

A custom attributes debugger at Checkout.

Bringing the sample selections into Checkout

Displaying the selected sample options at Checkout.

After the Section is in place, and the attributes are being properly set at the Cart, we can start to play with them in Checkout. I considered what to do with the product display for a while, and I eventually landed on what's likely an over-engineered solution. However, I think it shows off nicely what you can do with a Checkout UI Extension when it's properly configured.

Notice in the example that the selected sample products' Product IDs are stored in a json array. We can use that to fetch dynamic data into the Checkout UI Extension using it's Storefront API access.

Using the referenced Product IDs set as attributes in the Storefront API.

What this means is that if a staff member managing products updates and of the details, such as the product's title, or the main image for the referenced product(s), this component always pulls in that fresh data right from the source of truth. It just works. It would be easier to hardcode options and just dynamically display them based on chosen selection, but this is future-proofing the solution, and further establishes it's resuable intent.

Imight write in a future post about the 'gift packaging' and 'gift message' boxes that you can see around the Sample Selections in the Checkout UI Extension block -- those are built Liquid Sections operating in much the same way, but they're specific to the requirements I was building towards at the time.

This Checkout Display is Optional. Realistically, if a customer is happy to trust you that they're going to get their free samples, all of this Checkout UI Extension situation is not necessary. The resulting order will have a section that looks like the following, outputting the attributes set at the cart.

The Order Attributes after completeing the Checkout.

THE SAMPLE CODE

Installation Instructions

  • Navigate to the Theme Code Editor of your Online Store Theme
  • Add a Section titled 'Sample-Selection-Cart'
  • Paste the following code, then be sure to save the Theme.
  • Navigate to the Theme Editor and find the 'Cart' page.
  • Click 'Add Section' from the left hand sidebar, and find your new custom section with the title you provided.
Code
{%- if cart.item_count > 0 -%}
  {%- liquid
    assign section_id = 'cart-sample-selector--' | append: section.id
    assign sample_collection_handle = section.settings.sample_collection
    assign sample_collection = collections[sample_collection_handle]
    assign max_samples = section.settings.max_samples
    assign products_per_row = section.settings.products_per_row

    assign radio_label_select = section.settings.radio_label_select_template | replace: '{max_samples}', max_samples
    assign initial_selected_count = 0
    assign current_sample_selection_active = cart.attributes._sample_selection_active | default: 'false'
    assign current_selected_samples_str = cart.attributes._sample_products | default: ''
    if current_selected_samples_str != ''
      assign current_selected_samples_array = current_selected_samples_str | split: ','
      assign initial_selected_count = current_selected_samples_array.size
    else
      assign current_selected_samples_array = ''
    endif
    assign radio_label_select = radio_label_select | replace: '{selected_count}', initial_selected_count

    assign grid_column_class = 'one-half'
    if products_per_row == 3
      assign grid_column_class = 'one-third'
    endif
  -%}

  <div class="cart-sample-selector-section" id="{{ section_id }}" data-section-id="{{ section.id }}" data-max-samples="{{ max_samples }}">
    <div class="cart-sample-selector-section__inner-wrapper">
      <h2 class="cart-sample-selector__title h3">{{ section.settings.title | escape }}</h2>
    <p class="cart-sample-selector__intro">{{ section.settings.intro_text | escape }}</p>

    <div class="cart-sample-selector__options">
      <div class="cart-sample-selector__option">
        <input
          type="radio"
          id="sample-option-none-{{ section.id }}"
          name="sample_selection_active"
          value="false"
          class="cart-sample-selector__radio"
          {% if current_sample_selection_active == 'false' %}checked{% endif %}
        >
        <label for="sample-option-none-{{ section.id }}">{{ section.settings.radio_label_none | escape }}</label>
      </div>
      <div class="cart-sample-selector__option">
        <input
          type="radio"
          id="sample-option-select-{{ section.id }}"
          name="sample_selection_active"
          value="true"
          class="cart-sample-selector__radio"
          {% if current_sample_selection_active == 'true' %}checked{% endif %}
        >
        <label for="sample-option-select-{{ section.id }}" id="sample-select-label-{{ section.id }}">{{ radio_label_select | escape }}</label>
      </div>
    </div>

    <div
      class="cart-sample-selector__product-grid-container"
      id="sample-product-grid-container-{{ section.id }}"
      {% if current_sample_selection_active == 'false' %}style="display: none;"{% endif %}
    >
      {%- if sample_collection != blank and sample_collection.products_count > 0 -%}
        <div class="grid grid--{{ products_per_row }}-col-desktop grid--1-col-tablet-down">
          {%- for product in sample_collection.products limit: 6 -%}
            {%- liquid
              assign is_selected = false
              if current_selected_samples_array != ''
                for selected_id in current_selected_samples_array
                  if selected_id == product.id
                    assign is_selected = true
                    break
                  endif
                endfor
              endif
            -%}
            <div class="grid__item {{ grid_column_class }}">
              <div class="cart-sample-selector__product-item" data-product-id="{{ product.id }}">
                <div class="cart-sample-selector__product-checkbox-wrapper">
                  <input
                    type="radio" 
                    name="sample_product_selector_{{ section.id }}_{{ product.id }}" 
                    id="sample-product-{{ section.id }}-{{ product.id }}"
                    class="cart-sample-selector__product-radio" 
                    data-product-id="{{ product.id }}"
                    {% if is_selected %}checked{% endif %}
                  >
                  <label for="sample-product-{{ section.id }}-{{ product.id }}" class="cart-sample-selector__radio-label">
                     <span class="visually-hidden">Select {{ product.title | escape }}</span>
                  </label>
                </div>
                <div class="cart-sample-selector__product-image">
                  {%- if product.featured_image != blank -%}
                    <img
                      src="{{ product.featured_image | image_url: width: 225, height: 225 }}"
                      alt="{{ product.featured_image.alt | escape }}"
                      loading="lazy"
                      width="112"
                      height="112"
                    >
                  {%- else -%}
                    {{ 'product-1' | placeholder_svg_tag: 'placeholder-svg' }}
                  {%- endif -%}
                </div>
                <div class="cart-sample-selector__product-details">
                  <h4 class="cart-sample-selector__product-title">{{ product.title | escape }}</h4>
                  {%- if product.description != blank -%}
                    <p class="cart-sample-selector__product-description">{{ product.description | strip_html | truncatewords: 10, "..." | escape }}</p>
                  {%- endif -%}
                  <a href="{{ product.url }}" class="cart-sample-selector__product-link link">
                    See details <span aria-hidden="true">&rsaquo;</span>
                  </a>
                </div>
              </div>
            </div>
          {%- endfor -%}
        </div>
      {%- else -%}
        <p>The selected sample collection is empty or not found. Please select a valid collection in the theme editor.</p>
      {%- endif -%}
    </div>
  </div>
</div>

  <script>
    document.addEventListener('DOMContentLoaded', function() {
      const sectionElement = document.getElementById('{{ section_id }}');
      if (!sectionElement) return;

      const maxSamples = parseInt(sectionElement.dataset.maxSamples, 10);
      const radioButtons = sectionElement.querySelectorAll('.cart-sample-selector__radio'); // These are for choosing to select samples or not
      const productGridContainer = sectionElement.querySelector('#sample-product-grid-container-{{ section.id }}');
      const productRadios = sectionElement.querySelectorAll('.cart-sample-selector__product-radio'); // These are for selecting individual products
      const selectLabel = sectionElement.querySelector('#sample-select-label-{{ section.id }}');
      const originalSelectLabelTemplate = "{{ section.settings.radio_label_select_template | escape }}";

      let selectedSamples = [];

      // Initialize selectedSamples from cart attributes
      const initialSelectedSamplesStr = "{{ current_selected_samples_str }}";
      if (initialSelectedSamplesStr) {
        selectedSamples = initialSelectedSamplesStr.split(',');
      }
      
      updateUIBasedOnSelection();

      radioButtons.forEach(radio => {
        radio.addEventListener('change', function() {
          const showGrid = this.value === 'true';
          productGridContainer.style.display = showGrid ? '' : 'none';
          updateCartAttributes({ _sample_selection_active: this.value });
          if (!showGrid) {
            // If "No samples" is selected, clear existing selections
            selectedSamples = [];
            productRadios.forEach(radio => radio.checked = false);
            updateCartAttributes({ _sample_products: '' }); // Clear samples from cart
          }
          updateUIBasedOnSelection();
        });
      });

      // Revised logic for radio button selection/deselection
      productRadios.forEach(radio => {
        radio.addEventListener('click', function() {
          const productId = this.dataset.productId;
          const isCurrentlyCheckedInArray = selectedSamples.includes(productId);

          if (this.checked) { // Radio is being checked by the click
            if (isCurrentlyCheckedInArray) {
              // This means it was already checked and user is clicking it again to uncheck
              this.checked = false; // Manually uncheck
              selectedSamples = selectedSamples.filter(id => id !== productId);
            } else {
              // This is a new selection
              if (selectedSamples.length < maxSamples) {
                selectedSamples.push(productId);
                // No need to explicitly set this.checked = true, browser does it
              } else {
                // Max samples reached, prevent this selection
                this.checked = false; 
              }
            }
          } else {
            // Radio is being unchecked by the click (only possible if already checked and clicked again)
            // This path is taken if the above logic `this.checked = false` was executed
             selectedSamples = selectedSamples.filter(id => id !== productId);
          }
          
          // Sync all radio states with the selectedSamples array to ensure UI consistency
          productRadios.forEach(r => {
            r.checked = selectedSamples.includes(r.dataset.productId);
          });

          updateCartAttributes({ _sample_products: selectedSamples.join(',') });
          updateUIBasedOnSelection();
        });
      });

      function updateUIBasedOnSelection() {
        const currentSelectedCount = selectedSamples.length;

        // Update label
        let newLabel = originalSelectLabelTemplate.replace('{max_samples}', maxSamples);
        newLabel = newLabel.replace('{selected_count}', currentSelectedCount);
        selectLabel.textContent = newLabel;

        // Enable/disable radio buttons
        const limitReached = currentSelectedCount >= maxSamples;
        productRadios.forEach(radio => {
          const productItem = radio.closest('.cart-sample-selector__product-item');
          if (limitReached && !radio.checked) {
            radio.disabled = true;
            if (productItem) productItem.classList.add('disabled');
          } else {
            radio.disabled = false;
            if (productItem) productItem.classList.remove('disabled');
          }
        });
      }

      function updateCartAttributes(attributesToUpdate) {
        fetch('/cart/update.js', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'X-Requested-With': 'XMLHttpRequest'
          },
          body: JSON.stringify({ attributes: attributesToUpdate })
        })
        .then(response => response.json())
        .then(data => {
          console.log('Cart attributes updated:', data);
          // Potentially trigger a cart update event if other components need to react
          // document.dispatchEvent(new CustomEvent('cart:updated', { bubbles: true, detail: { cart: data } }));
        })
        .catch(error => {
          console.error('Error updating cart attributes:', error);
        });
      }
    });
  </script>

  {%- if current_sample_selection_active == 'true' and initial_selected_count >= max_samples -%}
    <script>
      // One-time script to ensure UI is correct on initial load if max samples already selected
      document.addEventListener('DOMContentLoaded', function() {
        const sectionElement = document.getElementById('{{ section_id }}');
        if (!sectionElement) return;
        const maxSamples = parseInt(sectionElement.dataset.maxSamples, 10);
        const productRadiosOnInit = sectionElement.querySelectorAll('.cart-sample-selector__product-radio');
        let currentSelectedCountOnInit = 0;
        productRadiosOnInit.forEach(radio => { if(radio.checked) currentSelectedCountOnInit++; });

        if (currentSelectedCountOnInit >= maxSamples) {
          productRadiosOnInit.forEach(radio => {
            const productItem = radio.closest('.cart-sample-selector__product-item');
            if (!radio.checked) {
              radio.disabled = true;
              if (productItem) productItem.classList.add('disabled');
            }
          });
        }
      });
    </script>
  {%- endif -%}

{%- endif -%}

{% schema %}
{
  "name": "Cart Sample Selector",
  "settings": [
    {
      "type": "text",
      "id": "title",
      "label": "Title",
      "default": "Sample Selection Title"
    },
    {
      "type": "text",
      "id": "intro_text",
      "label": "Introductory text",
      "default": "Choose your free samples."
    },
    {
      "type": "text",
      "id": "radio_label_none",
      "label": "Label for 'No samples' option",
      "default": "No, I don't want samples."
    },
    {
      "type": "text",
      "id": "radio_label_select_template",
      "label": "Label template for 'Select samples' option",
      "default": "Choose {max_samples} sample options. {selected_count}/{max_samples}",
      "info": "Use {max_samples} and {selected_count} as placeholders."
    },
    {
      "type": "collection",
      "id": "sample_collection",
      "label": "Sample product collection",
      "info": "Products from this collection will be shown as samples. Ensure it has at least 6 products for best display."
    },
    {
      "type": "range",
      "id": "max_samples",
      "min": 1,
      "max": 6,
      "step": 1,
      "label": "Maximum samples selectable",
      "default": 2
    },
    {
      "type": "range",
      "id": "products_per_row",
      "min": 1,
      "max": 3,
      "step": 1,
      "label": "Products per row (desktop)",
      "default": 2
    }
  ],
  "presets": [
    {
      "name": "Cart Sample Selector"
    }
  ]
}
{% endschema %}

{% stylesheet %}
.cart-sample-selector-section {
  /* Removed page-width from here, moved to inner-wrapper */
  margin-top: 2rem;
  margin-bottom: 2rem;
  padding-top: 1.5rem;
}

.cart-sample-selector-section__inner-wrapper {
  max-width: 50%; /* Half page width */
  margin-left: auto;
  margin-right: auto; /* Centered */
}

/* Responsive adjustment for smaller screens if 50% is too narrow */
@media screen and (max-width: 989px) { /* Adjust breakpoint as needed */
  .cart-sample-selector-section__inner-wrapper {
    max-width: 80%; /* Or 100% or whatever looks best */
  }
}
@media screen and (max-width: 749px) { /* Adjust breakpoint as needed */
  .cart-sample-selector-section__inner-wrapper {
    max-width: 100%; /* Full width on small screens */
  }
}

.cart-sample-selector__title {
  margin-bottom: 0.4rem; /* Reduced */
  /* Assuming h3 provides a base font size, if it's too large, we'd add font-size here */
  /* Example: font-size: 2.0rem; if h3 is larger */
}

.cart-sample-selector__intro {
  margin-bottom: 1.2rem; /* Reduced */
  font-size: 1.3rem; /* Reduced from 1.6rem */
}

.cart-sample-selector__options {
  margin-bottom: 1.5rem; /* Reduced */
}

.cart-sample-selector__option {
  display: flex;
  align-items: center;
  margin-bottom: 1rem;
}

.cart-sample-selector__option input[type="radio"] {
  margin-right: 0.8rem; /* Reduced */
  appearance: none;
  -webkit-appearance: none;
  width: 16px; /* Reduced from 20px */
  height: 16px; /* Reduced from 20px */
  border: 1.5px solid #000; /* Adjusted border */
  border-radius: 50%;
  outline: none;
  cursor: pointer;
  position: relative;
}

.cart-sample-selector__option input[type="radio"]:checked {
  border-color: #000; /* Black border when checked */
}

.cart-sample-selector__option input[type="radio"]:checked::before {
  content: '';
  display: block;
  width: 8px; /* Reduced from 10px */
  height: 8px; /* Reduced from 10px */
  background-color: #000; /* Black inner circle */
  border-radius: 50%;
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}


.cart-sample-selector__option label {
  font-size: 1.3rem; /* Reduced from 1.6rem */
  cursor: pointer;
}

.cart-sample-selector__product-grid-container {
  /* Styles for when it's visible */
}

.cart-sample-selector__product-item {
  display: flex;
  align-items: flex-start; /* Align items to the top */
  margin-bottom: 1.5rem; /* Reduced */
  padding: 0.8rem; /* Reduced */
  border: 1px solid transparent; /* For spacing, can be styled later */
  opacity: 1;
  transition: opacity 0.3s ease;
}

.cart-sample-selector__product-item.disabled {
  opacity: 0.5;
  pointer-events: none; /* To prevent interaction with links inside */
}
.cart-sample-selector__product-item.disabled .cart-sample-selector__product-checkbox-wrapper input[type="radio"] {
  cursor: not-allowed;
}


.cart-sample-selector__product-checkbox-wrapper {
  margin-right: 12px; /* Reduced */
  flex-shrink: 0; /* Prevent checkbox from shrinking */
  padding-top: 4px; /* Reduced */
}

.cart-sample-selector__product-radio { /* Ensure class name matches HTML if changed from product-checkbox */
  appearance: none;
  -webkit-appearance: none;
  width: 16px; /* Reduced from 20px */
  height: 16px; /* Reduced from 20px */
  border: 1.5px solid #000; /* Adjusted border */
  background-color: #fff;
  border-radius: 50%; /* Circular */
  cursor: pointer;
  position: relative;
  vertical-align: middle; 
}

.cart-sample-selector__product-radio:checked {
  border-color: #000; /* Ensure border is black */
  background-color: #fff; /* Ensure background remains white, dot will be black */
}

.cart-sample-selector__product-radio:checked::before {
  content: '';
  display: block;
  width: 8px; /* Reduced from 10px */
  height: 8px; /* Reduced from 10px */
  background-color: #000; /* Black inner dot */
  border-radius: 50%;
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}


.cart-sample-selector__product-image {
  width: 100px; /* Reduced from 125px */
  height: 100px; /* Reduced from 125px */
  margin-right: 12px; /* Reduced */
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}

.cart-sample-selector__product-image img {
  max-width: 100%;
  max-height: 100%;
  width: 100%; /* Ensure it fills container for object-fit */
  height: 100%; /* Ensure it fills container for object-fit */
  object-fit: contain; /* Ensures entire image is visible */
}

.cart-sample-selector__product-details {
  flex-grow: 1;
}

.cart-sample-selector__product-title {
  font-size: 1.2rem; /* Reduced from 1.4rem */
  margin: 0 0 0.2rem 0; /* Reduced */
  font-weight: bold; /* As per screenshot */
  text-transform: uppercase; /* As per screenshot */
}

.cart-sample-selector__product-description { /* Changed from vendor */
  font-size: 1.0rem; /* Reduced from 1.1rem */
  color: #555;
  margin: 0 0 0.4rem 0; /* Reduced */
  text-transform: none; /* Descriptions usually aren't uppercase */
  line-height: 1.3; /* Adjusted */
}

.cart-sample-selector__product-link {
  font-size: 1.0rem; /* Reduced from 1.2rem */
  text-decoration: none;
  color: #000; /* Black link */
}
.cart-sample-selector__product-link:hover {
  text-decoration: underline;
}

/* Grid classes from Dawn theme for reference if needed */
.grid {
  list-style: none;
  margin: 0;
  padding: 0;
  margin-left: -2rem; /* Gutter */
}
.grid__item {
  padding-left: 2rem; /* Gutter */
  width: 100%;
}

@media screen and (min-width: 750px) { /* Tablet and up */
  .grid--1-col-tablet-down {
    /* No change needed if it's already 1 col by default */
  }
}

@media screen and (min-width: 990px) { /* Desktop */
  .grid--2-col-desktop {
    display: flex;
    flex-wrap: wrap;
  }
  .grid--2-col-desktop > .grid__item.one-half {
    width: 50%;
  }
  .grid--3-col-desktop {
    display: flex;
    flex-wrap: wrap;
  }
  .grid--3-col-desktop > .grid__item.one-third {
    width: 33.3333%;
  }
}

.visually-hidden {
  position: absolute !important;
  overflow: hidden;
  clip: rect(0 0 0 0);
  height: 1px;
  width: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
}
{% endstylesheet %}