Subscriptions (subscribe and save)

Hi, is it possible to make custom button like the picture below? When we click subscribe and save, it will show the purchase option for subscribe and save (delivery frequency and so on). Then we click one-time purchase, it will show our normal purchase details from shopify. Is it possible?

I already surveying few apps but most of them didnt offer the design like this

this design is possible, but it requires either checkout extensions (Shopify Plus only) or custom development. That’s why most standard apps can’t deliver it their UI is locked to their built-in patterns.

What plan are you on? That’s the real constraint here.

pass : rteobi

Horizon theme and i just add the custom liquid button (idk what to do after that)

This App

It says the code has errors

{% if product.selling_plan_groups.size > 0 %}

<div class="custom-purchase-options" id="custom-purchase-options-{{ section.id }}">

<div class="purchase-option purchase-option--active" data-type="subscription">
  <label>
    <input type="radio" name="purchase-type-{{ section.id }}" value="subscription" checked>
    <span class="purchase-option__content">
      <strong>Subscribe &amp; Save</strong>
      <small>Save on every delivery</small>
    </span>
  </label>
</div>

<div class="subscription-plans">
  {% for group in product.selling_plan_groups %}
    {% for plan in group.selling_plans %}
      <label class="subscription-plan">
        <input
          type="radio"
          name="selling-plan-{{ section.id }}"
          value="{{ plan.id }}"
          {% if forloop.first %}checked{% endif %}
        >
        <span>{{ plan.name }}</span>
      </label>
    {% endfor %}
  {% endfor %}
</div>

<div class="purchase-option" data-type="one-time">
  <label>
    <input type="radio" name="purchase-type-{{ section.id }}" value="one-time">
    <span class="purchase-option__content">
      <strong>One-time Purchase</strong>
      <small>Buy once at the regular price</small>
    </span>
  </label>
</div>
#custom-purchase-options-{{ section.id }} { margin: 20px 0; } #custom-purchase-options-{{ section.id }} .purchase-option { border: 1px solid rgba(0,0,0,.2); border-radius: 8px; padding: 15px; margin-bottom: 10px; cursor: pointer; } #custom-purchase-options-{{ section.id }} .purchase-option--active { border-color: #111; background: #fafafa; } #custom-purchase-options-{{ section.id }} label { display: flex; align-items: center; gap: 10px; cursor: pointer; } #custom-purchase-options-{{ section.id }} .purchase-option__content { display: flex; flex-direction: column; gap: 4px; } #custom-purchase-options-{{ section.id }} small { opacity: .65; } #custom-purchase-options-{{ section.id }} .subscription-plans { padding: 0 15px 15px 40px; } #custom-purchase-options-{{ section.id }} .subscription-plan { margin: 8px 0; font-size: 14px; }

<script
(function() {
const container = document.getElementById(
'custom-purchase-options-{{ section.id }}'
);
if (!container) return;
const subscriptionOption = container.querySelector(
'[data-type="subscription"]'
);
const oneTimeOption = container.querySelector(
'[data-type="one-time"]'
);
const subscriptionPlans = container.querySelector(
'.subscription-plans'
);
function getProductForm() {
return document.querySelector('form[action*="/cart/add"]');
}
function updateSellingPlan(planId) {
const form = getProductForm();
if (!form) return;
let input = form.querySelector(
'input[name="selling_plan"]'
);
if (!input) {
input = document.createElement('input');
input.type = 'hidden';
input.name = 'selling_plan';
form.appendChild(input);
}
input.value = planId || '';
}
function selectSubscription() {
subscriptionOption.classList.add('purchase-option--active');
oneTimeOption.classList.remove('purchase-option--active');
subscriptionPlans.style.display = 'block';
const selectedPlan = container.querySelector(
'input[name="selling-plan-{{ section.id }}"]:checked'
);
updateSellingPlan(
selectedPlan ? selectedPlan.value : ''
);
}
function selectOneTime() {
oneTimeOption.classList.add('purchase-option--active');
subscriptionOption.classList.remove('purchase-option--active');
subscriptionPlans.style.display = 'none';
updateSellingPlan('');
}
subscriptionOption.addEventListener('click', selectSubscription);
oneTimeOption.addEventListener('click', selectOneTime);
container
.querySelectorAll('input[name="selling-plan-{{ section.id }}"]')
.forEach(function(input) {
input.addEventListener('change', function() {
updateSellingPlan(this.value);
});
});
selectSubscription();
})();
dif %}

Can you help me? I already make the custom button

Hi again!

You do not need Shopify Plus.

Tested on a Horizon store with a live subscription app u mentioned above.

Result:

Where it goes

  • Online Store > Themes > Customize, open a product page
  • In the Custom Liquid block you already added (keep it in the product area, above Add to cart), paste the code below, then Save

The code

{%- assign cw_product = closest.product | default: product -%}
{%- if cw_product.selling_plan_groups.size > 0 -%}
  {%- assign cw_group = cw_product.selling_plan_groups | where: 'name', 'Subscribe & Save' | first -%}
  {%- unless cw_group -%}{%- assign cw_group = cw_product.selling_plan_groups.first -%}{%- endunless -%}
  {%- assign cw_uid = block.id | default: section.id -%}

  <style>
    .cw-po{--cw-accent:#2f5d54;--cw-soft:#f3efe9;margin:0 0 1.1rem;font-family:inherit}
    .cw-po__seg{display:flex;border-radius:12px;overflow:hidden;border:1px solid rgba(0,0,0,.08)}
    .cw-po__btn{flex:1;padding:.85rem 1rem;border:0;background:var(--cw-soft);cursor:pointer;font:inherit;font-weight:600;color:#2a2a2a;line-height:1.25;text-align:center;transition:background .15s,color .15s}
    .cw-po__btn small{display:block;font-weight:500;font-size:.78em;opacity:.8;margin-top:.1rem}
    .cw-po__btn.is-active{background:var(--cw-accent);color:#fff}
    .cw-po__body{margin-top:.85rem}
    .cw-po__price{font-size:1.6rem;font-weight:700;color:var(--cw-accent);line-height:1.1}
    .cw-po__note{font-size:.9rem;color:#555;margin-top:.2rem}
    .cw-po__freq{margin-top:.7rem}
    .cw-po__freq label{display:block;font-size:.8rem;color:#555;margin-bottom:.3rem}
    .cw-po__freq select{width:100%;padding:.6rem .7rem;border:1px solid rgba(0,0,0,.22);border-radius:8px;font:inherit;background:#fff}
    .cw-po__freq[hidden]{display:none}
  </style>

  <div class="cw-po" data-cw-po data-section="{{ cw_uid }}">
    <div class="cw-po__seg">
      <button type="button" class="cw-po__btn is-active" data-mode="subscription">
        Subscribe and Save<small data-cw-save></small>
      </button>
      <button type="button" class="cw-po__btn" data-mode="onetime">
        One time purchase<small>Standard price</small>
      </button>
    </div>
    <div class="cw-po__body">
      <div class="cw-po__price" data-cw-price></div>
      <div class="cw-po__note" data-cw-note></div>
      <div class="cw-po__freq" data-cw-freq>
        <label for="cw-freq-{{ cw_uid }}">Delivery frequency</label>
        <select id="cw-freq-{{ cw_uid }}" data-cw-plan>
          {%- for plan in cw_group.selling_plans -%}
            <option value="{{ plan.id }}">{{ plan.name }}</option>
          {%- endfor -%}
        </select>
      </div>
    </div>
  </div>

  <script>
    (function(){
      var DATA = {
        {%- for variant in cw_product.variants -%}
        "{{ variant.id }}": {
          oneTime: {{ variant.price | json }},
          oneTimeStr: {{ variant.price | money | json }},
          plans: {
            {%- for alloc in variant.selling_plan_allocations -%}
            "{{ alloc.selling_plan.id }}": { price: {{ alloc.price | json }}, priceStr: {{ alloc.price | money | json }}, name: {{ alloc.selling_plan.name | json }} }{% unless forloop.last %},{% endunless %}
            {%- endfor -%}
          }
        }{% unless forloop.last %},{% endunless %}
        {%- endfor -%}
      };
      var SECTION = {{ cw_uid | json }};

      function init(){
        var root = document.querySelector('[data-cw-po][data-section="'+SECTION+'"]');
        if(!root || root.dataset.cwInit) return;
        var form = (root.closest('form[data-type="add-to-cart-form"]'))
          || (root.closest('.shopify-section, product-information, .product, [class*="product"]') || document).querySelector('form[data-type="add-to-cart-form"]')
          || document.querySelector('form[data-type="add-to-cart-form"]');
        if(!form) return;
        root.dataset.cwInit = "1";

        var variantInput = form.querySelector('input[name="id"]');
        var planSelect = root.querySelector('[data-cw-plan]');
        var priceEl = root.querySelector('[data-cw-price]');
        var noteEl = root.querySelector('[data-cw-note]');
        var freqEl = root.querySelector('[data-cw-freq]');
        var saveEl = root.querySelector('[data-cw-save]');
        var btns = root.querySelectorAll('.cw-po__btn');
        var mode = 'subscription';

        function vData(){
          var keys = Object.keys(DATA);
          var vid = variantInput && DATA[variantInput.value] ? variantInput.value : keys[0];
          return DATA[vid] || {};
        }
        function planInput(create){
          var i = form.querySelector('input[name="selling_plan"][data-cw]');
          if(!i && create){ i=document.createElement('input'); i.type='hidden'; i.name='selling_plan'; i.setAttribute('data-cw',''); form.appendChild(i); }
          return i;
        }
        function saveText(vd, id){
          var p=vd.plans&&vd.plans[id]; if(!p||!vd.oneTime) return '';
          var pct=Math.round((1-(p.price/vd.oneTime))*1000)/10;
          return pct>0?(' · save '+pct+'%'):'';
        }
        function render(){
          var vd=vData();
          var id=planSelect?planSelect.value:(vd.plans?Object.keys(vd.plans)[0]:'');
          btns.forEach(function(b){var on=b.dataset.mode===mode;b.classList.toggle('is-active',on);});
          if(mode==='subscription'){
            freqEl.hidden=false;
            var p=vd.plans&&vd.plans[id];
            priceEl.textContent=p?p.priceStr:(vd.oneTimeStr||'');
            var freqText=planSelect&&planSelect.selectedIndex>-1?planSelect.options[planSelect.selectedIndex].textContent:'';
            if(freqText.indexOf(',')>-1) freqText=freqText.slice(freqText.lastIndexOf(',')+1).trim();
            noteEl.textContent=freqText;
            if(saveEl) saveEl.textContent=saveText(vd,id);
            var inp=planInput(true); if(inp) inp.value=id;
          } else {
            freqEl.hidden=true;
            priceEl.textContent=vd.oneTimeStr||'';
            noteEl.textContent='One-time purchase';
            var ex=planInput(false); if(ex) ex.remove();
          }
        }
        btns.forEach(function(b){b.addEventListener('click',function(){mode=b.dataset.mode;render();});});
        if(planSelect) planSelect.addEventListener('change',render);
        form.addEventListener('change',function(){setTimeout(render,60);});
        render();
      }
      if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',init);
      else init();
      document.addEventListener('shopify:section:load',init);
    })();
  </script>
{%- endif -%}

Notes:

  • It reads your subscription app’s plans automatically. Whatever delivery options you set up (weekly, monthly, and so on) appear in the frequency dropdown.
  • Subscribe shows the discounted price and the frequency picker. One time shows the normal price and hides the picker.
  • Change the accent colour on the first CSS line (--cw-accent).
  • Horizon still shows its own price above the widget. Leave it, or hide the theme price block on the product if you want only the widget’s price.

Regards,
Ajay

Great it works so far, how to make the button selector more clean and not too big?

And where the exact position if i want to add installment html only for one time purchase (i already have the html)

They live in the same Custom Liquid block.

Replace the block’s code with the version below.

Now

  1. the selector is now two slim pills instead of tall blocks,
  2. it has a marked spot for your installment HTML that only appears on One time.

Add your installment HTML

  • In the code, find these two lines near the bottom of the block:
    <!-- INSTALLMENT HTML START --> and <!-- INSTALLMENT HTML END -->
  • Paste your installment HTML between them
  • It shows only when One time is selected, and hides on Subscribe. The delivery frequency hides at the same time.

The code

{%- assign cw_product = closest.product | default: product -%}
{%- if cw_product.selling_plan_groups.size > 0 -%}
  {%- assign cw_group = cw_product.selling_plan_groups | where: 'name', 'Subscribe & Save' | first -%}
  {%- unless cw_group -%}{%- assign cw_group = cw_product.selling_plan_groups.first -%}{%- endunless -%}
  {%- assign cw_uid = block.id | default: section.id -%}

  <style>
    .cw-po{--cw-accent:#2f5d54;--cw-line:rgba(0,0,0,.14);margin:0 0 1rem;font-family:inherit}
    .cw-po__seg{display:flex;gap:.5rem}
    .cw-po__btn{flex:1;display:flex;align-items:center;justify-content:center;gap:.4rem;
      padding:.55rem .6rem;border:1px solid var(--cw-line);border-radius:8px;background:#fff;
      cursor:pointer;font:inherit;font-weight:600;font-size:.9rem;color:#2a2a2a;line-height:1.2;
      text-align:center;transition:border-color .15s,box-shadow .15s,background .15s}
    .cw-po__btn small{font-weight:600;font-size:.72em;color:var(--cw-accent)}
    .cw-po__btn.is-active{border-color:var(--cw-accent);box-shadow:inset 0 0 0 1px var(--cw-accent);background:#fff}
    .cw-po__btn.is-active small{color:var(--cw-accent)}
    .cw-po__body{margin-top:.6rem}
    .cw-po__price{font-size:1.15rem;font-weight:700;color:var(--cw-accent);line-height:1.1}
    .cw-po__note{font-size:.8rem;color:#666;margin-top:.15rem}
    .cw-po__freq{margin-top:.55rem}
    .cw-po__freq label{display:block;font-size:.75rem;color:#666;margin-bottom:.25rem}
    .cw-po__freq select{width:100%;padding:.5rem .6rem;border:1px solid var(--cw-line);border-radius:7px;font:inherit;background:#fff}
    .cw-po__freq[hidden],.cw-po__ot[hidden]{display:none}
    .cw-po__ot{margin-top:.6rem}
  </style>

  <div class="cw-po" data-cw-po data-section="{{ cw_uid }}">
    <div class="cw-po__seg">
      <button type="button" class="cw-po__btn is-active" data-mode="subscription">
        Subscribe and Save <small data-cw-save></small>
      </button>
      <button type="button" class="cw-po__btn" data-mode="onetime">
        One time
      </button>
    </div>
    <div class="cw-po__body">
      <div class="cw-po__price" data-cw-price></div>
      <div class="cw-po__note" data-cw-note></div>

      <div class="cw-po__freq" data-cw-freq>
        <label for="cw-freq-{{ cw_uid }}">Delivery frequency</label>
        <select id="cw-freq-{{ cw_uid }}" data-cw-plan>
          {%- for plan in cw_group.selling_plans -%}
            <option value="{{ plan.id }}">{{ plan.name }}</option>
          {%- endfor -%}
        </select>
      </div>

      {%- comment -%} Shows only when "One time" is selected. Paste your installment HTML between the two marks. {%- endcomment -%}
      <div class="cw-po__ot" data-cw-ot hidden>
        <!-- INSTALLMENT HTML START -->

        <!-- INSTALLMENT HTML END -->
      </div>
    </div>
  </div>

  <script>
    (function(){
      var DATA = {
        {%- for variant in cw_product.variants -%}
        "{{ variant.id }}": {
          oneTime: {{ variant.price | json }},
          oneTimeStr: {{ variant.price | money | json }},
          plans: {
            {%- for alloc in variant.selling_plan_allocations -%}
            "{{ alloc.selling_plan.id }}": { price: {{ alloc.price | json }}, priceStr: {{ alloc.price | money | json }}, name: {{ alloc.selling_plan.name | json }} }{% unless forloop.last %},{% endunless %}
            {%- endfor -%}
          }
        }{% unless forloop.last %},{% endunless %}
        {%- endfor -%}
      };
      var SECTION = {{ cw_uid | json }};

      function init(){
        var root = document.querySelector('[data-cw-po][data-section="'+SECTION+'"]');
        if(!root || root.dataset.cwInit) return;
        var form = (root.closest('form[data-type="add-to-cart-form"]'))
          || (root.closest('.shopify-section, product-information, .product, [class*="product"]') || document).querySelector('form[data-type="add-to-cart-form"]')
          || document.querySelector('form[data-type="add-to-cart-form"]');
        if(!form) return;
        root.dataset.cwInit = "1";

        var variantInput = form.querySelector('input[name="id"]');
        var planSelect = root.querySelector('[data-cw-plan]');
        var priceEl = root.querySelector('[data-cw-price]');
        var noteEl = root.querySelector('[data-cw-note]');
        var freqEl = root.querySelector('[data-cw-freq]');
        var otEl = root.querySelector('[data-cw-ot]');
        var saveEl = root.querySelector('[data-cw-save]');
        var btns = root.querySelectorAll('.cw-po__btn');
        var mode = 'subscription';

        function vData(){
          var keys = Object.keys(DATA);
          var vid = variantInput && DATA[variantInput.value] ? variantInput.value : keys[0];
          return DATA[vid] || {};
        }
        function planInput(create){
          var i = form.querySelector('input[name="selling_plan"][data-cw]');
          if(!i && create){ i=document.createElement('input'); i.type='hidden'; i.name='selling_plan'; i.setAttribute('data-cw',''); form.appendChild(i); }
          return i;
        }
        function saveText(vd, id){
          var p=vd.plans&&vd.plans[id]; if(!p||!vd.oneTime) return '';
          var pct=Math.round((1-(p.price/vd.oneTime))*1000)/10;
          return pct>0?('save '+pct+'%'):'';
        }
        function render(){
          var vd=vData();
          var id=planSelect?planSelect.value:(vd.plans?Object.keys(vd.plans)[0]:'');
          btns.forEach(function(b){var on=b.dataset.mode===mode;b.classList.toggle('is-active',on);});
          if(mode==='subscription'){
            freqEl.hidden=false; if(otEl) otEl.hidden=true;
            var p=vd.plans&&vd.plans[id];
            priceEl.textContent=p?p.priceStr:(vd.oneTimeStr||'');
            var freqText=planSelect&&planSelect.selectedIndex>-1?planSelect.options[planSelect.selectedIndex].textContent:'';
            if(freqText.indexOf(',')>-1) freqText=freqText.slice(freqText.lastIndexOf(',')+1).trim();
            noteEl.textContent=freqText;
            if(saveEl) saveEl.textContent=saveText(vd,id);
            var inp=planInput(true); if(inp) inp.value=id;
          } else {
            freqEl.hidden=true; if(otEl) otEl.hidden=false;
            priceEl.textContent=vd.oneTimeStr||'';
            noteEl.textContent='One-time purchase';
            if(saveEl) saveEl.textContent='';
            var ex=planInput(false); if(ex) ex.remove();
          }
        }
        btns.forEach(function(b){b.addEventListener('click',function(){mode=b.dataset.mode;render();});});
        if(planSelect) planSelect.addEventListener('change',render);
        form.addEventListener('change',function(){setTimeout(render,60);});
        render();
      }
      if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',init);
      else init();
      document.addEventListener('shopify:section:load',init);
    })();
  </script>
{%- endif -%}

Adjust the size

  • Button text size: the font-size:.9rem on .cw-po__btn
  • Price size: the font-size:1.15rem on .cw-po__price
  • Accent colour: the --cw-accent value on the first CSS line

Tested on a Horizon store: the slim selector keeps Subscribe and One time in one row, and the installment box renders only in One time with the subscription plan removed from the cart.

Regards,
Ajay