At the moment, the Focal theme doesn’t include a subscribe form by default, so we’ll need to add one ourselves. Shopify offers a built-in subscription schema, but you can only use it if you’ve signed up for a subscription add-on like Shopify Subscriptions or Recharge.
For example, I once worked with a client who wanted to offer subscription products in certain regions, so those products would only appear where subscriptions were available.
The main thing here is that the subscription form needs to work with all the UI components—like quick buy, the top product form, and the bottom product form. Plus, it should be available as a dynamic UI element, so it shows up whenever someone triggers it with a variation switch or other actions.
To make this work, you’ll want to create a subscription block in your liquid section
{% when 'subscription' %}
{% if product.selected_or_first_available_variant.selling_plan_allocations.size > 0 %}
<script type="application/json" data-countries>
{
"countries": [
{% for country in localization.available_countries -%}
{{ country.iso_code }}{% unless forloop.last %},{% endunless %}
{%- endfor %}
]
}
</script>
{% if product.variants.size == 1 %}
{% assign variant_ids = product.selected_or_first_available_variant.selling_plan_allocations | map: 'selling_plan_group_id' | uniq %}
{% for variant_id in variant_ids %}
{%liquid
assign group = product | map: 'selling_plan_groups' | where: 'id', group_id | first
assign allocations = product.selected_or_first_available_variant | map: 'selling_plan_allocations' | where: 'selling_plan_group_id', group_id
%}
{% for allocation in allocations %}
{%liquid
assign allocation_price = allocation.price | money | escape
assign allocation_compared_at_price = allocation.compare_at_price | money | escape
assign savings = allocation.compare_at_price | minus: allocation.price
assign savings_percentage = savings | times: 100 | divided_by: allocation.compare_at_price | round: 1
%}
{% endfor %}
{% endfor %}
<script type="application/json" data-selling-plans>
{
"groups": [
{% for variant_id in variant_ids %}
{
"id": "{{ variant_id }}",
"name": "{{ group.name }}",
"plans": [
{% for allocation in allocations %}
{
"id": "{{ allocation.selling_plan.id }}",
"name": "{{ allocation.selling_plan.name }}",
"price": "{{ allocation_price }}",
"compare_at_price": "{{ allocation_compared_at_price }}",
"savings_percentage": {{ savings_percentage }}
}{% unless forloop.last %},{% endunless %}
{% endfor %}
]
}{% unless forloop.last %},{% endunless %}
{% endfor %}
]
}
</script>
{% else %}
{% assign group_ids = variant.selling_plan_allocations | map: 'selling_plan_group_id' | uniq %}
{% for group_id in group_ids %}
{%liquid
assign group = product | map: 'selling_plan_groups' | where: 'id', group_id | first
assign allocations = variant | map: 'selling_plan_allocations' | where: 'selling_plan_group_id', group_id
%}
{% for allocation in allocations %}
{%liquid
assign allocation_price = allocation.price | money | escape
assign allocation_compared_at_price = allocation.compare_at_price | money | escape
assign savings = allocation.compare_at_price | minus: allocation.price
assign savings_percentage = savings | times: 100 | divided_by: allocation.compare_at_price | round: 1
%}
{% endfor %}
{% endfor %}
<script type="application/json" data-selling-plans>
{
"groups": [
{% for group_id in group_ids %}
{
"id": "{{ group_id }}",
"name": "{{ group.name }}",
"plans": [
{% for allocation in allocations %}
{
"id": "{{ allocation.selling_plan.id }}",
"name": "{{ allocation.selling_plan.name }}",
"price": "{{ allocation_price }}",
"compare_at_price": "{{ allocation_compared_at_price }}",
"savings_percentage": {{ savings_percentage }}
}{% unless forloop.last %},{% endunless %}
{% endfor %}
]
}{% unless forloop.last %},{% endunless %}
{% endfor %}
]
}
</script>
{% endif %}
{% if product.variants.size == 1 %}
<single-variant>
<script type="application/json" data-variant>
{{- product.selected_or_first_available_variant | json -}}
</script>
</single-variant>
{% endif %}
<subscription-form data-block-type="subscription" data-block-id="{{ section.id }}" handle="{{ product.handle }}" section-id="{{ section.id }}" form-id="{{ product_form_id }}" class="product-form__subscription" {{ block.shopify_attributes }} data-markets="{{ localization.country.iso_code }}">
<div class="product-form__subscription-form"></div>
</subscription-form>
{% endif %}
and handle the logic in your theme.js file.
// Custom Subscription Form Start
var SubscriptionForm = class extends HTMLElement {
connectedCallback() {
// Get initial variant ID from the variant picker
const variantPicker = document.querySelector('variant-picker') || document.querySelector('single-variant');
if (variantPicker) {
const initialVariant = variantPicker.querySelector('script[data-variant]');
if (initialVariant) {
const variant = JSON.parse(initialVariant.textContent);
this.subscriptionForm(variant);
}
}
// Listen for variant changes
const form = document.getElementById(this.getAttribute("form-id"));
form?.addEventListener("variant:changed", this._onVariantChanged.bind(this));
}
_onVariantChanged(event) {
// Get the selected variant from the event. If the first load, the variant will be the first available variant.
let selectedVariant = event.detail.variant;
if (!event.detail.previousVariant) {
// Get the variant picker element which contains all variants
const variantPicker = document.querySelector('variant-picker') || document.querySelector('single-variant');
if (variantPicker) {
// Get the first available variant from the variant picker
const firstAvailableVariant = variantPicker.querySelector('script[data-variant]');
if (firstAvailableVariant) {
selectedVariant = JSON.parse(firstAvailableVariant.textContent);
}
}
}
// Refresh the subscription form with the selected variant
this.subscriptionForm(selectedVariant);
}
subscriptionForm(selectedVariant) {
// get json data from script with attribute data-selling-plans
const sellingPlansScript = document.querySelector('script[data-selling-plans]');
const sellingPlansData = sellingPlansScript ? JSON.parse(sellingPlansScript.textContent) : null;
// Create a copy of the variant to avoid mutating the original
const variant = JSON.parse(JSON.stringify(selectedVariant));
// Map selling plans to allocations if available
if (sellingPlansData && sellingPlansData.groups && sellingPlansData.groups[0] && sellingPlansData.groups[0].plans) {
variant.selling_plan_allocations = variant.selling_plan_allocations.map((allocation, index) => {
const plan = sellingPlansData.groups[0].plans[index];
return {
...allocation,
selling_plan_group_id: plan ? plan.name : allocation.selling_plan_group_id
};
});
}
let formWrapper = document.querySelector(".product-form__subscription-form");
// if selected variant has selling plan allocations, show the selling plan allocations as options selectable
if (variant.selling_plan_allocations.length > 0) {
// get calculateServingNumber from variant
const calculateServingNumber = variant.option2
? parseInt(variant.option2.replace(/[^0-9]/g, ''))
: 1;
// get value of data-markets attribute from subscription-form
const markets = document.querySelector('subscription-form').getAttribute('data-markets');
// if markets is not empty, show the selling plan allocations as options selectable
formWrapper.innerHTML = `
<div class="subscription-options">
<div class="purchase-options">
<div class="purchase-option">
<input type="radio" id="one-time-purchase" name="purchase_type" value="one-time" ${markets === 'US' ? '' : 'checked'}>
<label for="one-time-purchase" class="purchase-label">
<span class="purchase-title">One time purchase</span>
<span class="purchase-price">
<strong>${formatMoney(variant.price)}</strong>
<div class="purchase-price-per-serving">${formatMoney(variant.price/calculateServingNumber)} per serving</div>
</span>
</label>
</div>
</div>
${markets === 'US' ? `
<div class="subscription-section" ${markets === 'US' ? '' : 'disabled'}>
<div class="subscription-header">
<input type="radio" id="subscription-purchase" name="purchase_type" value="subscription" ${markets === 'US' ? 'checked' : ''}>
<label for="subscription-purchase" class="subscription-label">
<span class="subscription-title">Set up auto-ship ${variant.selling_plan_allocations.map((allocation, index) => `
${index === 0 ? `
<span class="subscription-price">
<div>
<span class="subscription-price-compare">${formatMoney(allocation.compare_at_price)}</span>
<span class="subscription-price-current"><strong>${formatMoney(allocation.price_adjustments[0].price)}</strong></span>
</div>
<div class="subscription-price-per-serving">${formatMoney(allocation.price_adjustments[0].price/calculateServingNumber)} per serving</div>
</span>
` : ''}
`).join('')}
</span>
</label>
</div>
<div class="subscription-options-list">
<div class="subscription-options-list-header">
<div class="discount-info">
<span class="discount-price heading h6">${variant.selling_plan_allocations.map((basePrice, index) => `${index === 0 ? `${formatMoney(basePrice.compare_at_price * 0.9)}` : ''}`).join('')}</span>
<span class="discount-percentage heading h5">10% OFF</span>
<span class="discount-type heading h6">NOW</span>
</div>
<div>
<svg width="19" height="16" viewBox="0 0 19 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.5611 8.70711C18.9516 8.31658 18.9516 7.68342 18.5611 7.29289L12.1971 0.928932C11.8066 0.538408 11.1735 0.538408 10.7829 0.928932C10.3924 1.31946 10.3924 1.95262 10.7829 2.34315L16.4398 8L10.7829 13.6569C10.3924 14.0474 10.3924 14.6805 10.7829 15.0711C11.1735 15.4616 11.8066 15.4616 12.1971 15.0711L18.5611 8.70711ZM0.680664 9H17.854V7H0.680664V9Z" fill="#B3B3B3"></path>
</svg>
</div>
<div class="discount-info">
<span class="discount-price heading h6">${variant.selling_plan_allocations.map((basePrice, index) => `${index === 0 ? `${formatMoney(basePrice.compare_at_price * 0.8)}` : ''}`).join('')}</span>
<span class="discount-percentage heading h5">20% OFF</span>
<span class="discount-type heading h6">NEXT ORDER</span>
</div>
<div>
<svg width="19" height="16" viewBox="0 0 19 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.5611 8.70711C18.9516 8.31658 18.9516 7.68342 18.5611 7.29289L12.1971 0.928932C11.8066 0.538408 11.1735 0.538408 10.7829 0.928932C10.3924 1.31946 10.3924 1.95262 10.7829 2.34315L16.4398 8L10.7829 13.6569C10.3924 14.0474 10.3924 14.6805 10.7829 15.0711C11.1735 15.4616 11.8066 15.4616 12.1971 15.0711L18.5611 8.70711ZM0.680664 9H17.854V7H0.680664V9Z" fill="#B3B3B3"></path>
</svg>
</div>
<div class="discount-info">
<span class="discount-price heading h6">${variant.selling_plan_allocations.map((basePrice, index) => `${index === 0 ? `${formatMoney(basePrice.compare_at_price * 0.7)}` : ''}`).join('')}</span>
<span class="discount-percentage heading h5">30% OFF</span>
<span class="discount-type heading h6">3+ ORDERS</span>
</div>
</div>
<select name="selling_plan" class="subscription-select">
${variant.selling_plan_allocations.map((allocation, index) => `
<option
value="${allocation.selling_plan_id}"
data-price="${allocation.price}"
${index === 0 ? 'selected' : ''}>
${allocation.selling_plan_group_id}
</option>
`).join('')}
</select>
</div>
</div>
` : `
`}
</div>
`;
// Add event listeners for purchase type selection
const purchaseTypeRadios = formWrapper.querySelectorAll('input[name="purchase_type"]');
// get value of checked purchase_type input
const checkedPurchaseType = Array.from(purchaseTypeRadios).find(radio => radio.checked).value;
// Get all buy buttons containers
const productBuyButtons = document.querySelectorAll('div[data-block-type="buy-buttons"]');
const quickBuyDrawerElement = document.querySelector('quick-buy-drawer');
const quickBuyPopoverElement = document.querySelector('quick-buy-popover');
const productStickyElement = document.querySelector('product-sticky-form');
// iOS-optimized button text update function
const updateButtonText = (container) => {
const submitButtonTexts = container.querySelectorAll('button[type="submit"] span.loader-button__text');
const newText = checkedPurchaseType === 'subscription' ? 'Subscribe' : 'Add to cart';
submitButtonTexts.forEach(textElement => {
// iOS Safari fix: Use innerHTML instead of textContent for better rendering
if (textElement.innerHTML !== newText) {
textElement.innerHTML = newText;
// Force reflow on iOS Safari to ensure text update is rendered
if (/iPad|iPhone|iPod/.test(navigator.userAgent)) {
textElement.style.display = 'none';
textElement.offsetHeight; // Force reflow
textElement.style.display = '';
}
}
});
};
// Update buttons in both regular product page and quick-buy-drawer
const initializeButtonTexts = () => {
productBuyButtons.forEach(container => updateButtonText(container));
if (quickBuyDrawerElement) {
updateButtonText(quickBuyDrawerElement);
}
if (quickBuyPopoverElement) {
updateButtonText(quickBuyPopoverElement);
}
if (productStickyElement) {
updateButtonText(productStickyElement);
}
};
// Use requestAnimationFrame to ensure DOM is ready
requestAnimationFrame(() => {
initializeButtonTexts();
// Also try again after a short delay to catch any late-loading elements
setTimeout(initializeButtonTexts, 100);
});
// Add MutationObserver to handle dynamically loaded content
const observer = new MutationObserver((mutations) => {
let shouldUpdate = false;
mutations.forEach((mutation) => {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
// Check if any new button elements were added
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.matches && (node.matches('button[type="submit"]') || node.querySelector('button[type="submit"]'))) {
shouldUpdate = true;
}
}
});
}
});
if (shouldUpdate) {
// Small delay to ensure new elements are fully rendered
setTimeout(initializeButtonTexts, 50);
}
});
// Observe the document body for new elements
observer.observe(document.body, {
childList: true,
subtree: true
});
purchaseTypeRadios.forEach(radio => {
radio.addEventListener('change', (event) => {
const selectedType = event.target.value;
// Enable/disable subscription select based on selection
const subscriptionSelectContainer = formWrapper.querySelector('.subscription-options-list');
const subscriptionSelect = formWrapper.querySelector('.subscription-select');
subscriptionSelect.disabled = selectedType !== 'subscription';
subscriptionSelectContainer.style.display = selectedType !== 'subscription' ? 'none' : 'block';
// Update button text in both regular product page and quick-buy-drawer
productBuyButtons.forEach(container => {
const submitButtonTexts = container.querySelectorAll('button[type="submit"] span.loader-button__text');
submitButtonTexts.forEach(textElement => {
// iOS Safari fix: Use innerHTML instead of textContent for better rendering
const newText = selectedType === 'subscription' ? 'Subscribe' : 'Add to cart';
if (textElement.innerHTML !== newText) {
textElement.innerHTML = newText;
// Force reflow on iOS Safari to ensure text update is rendered
if (/iPad|iPhone|iPod/.test(navigator.userAgent)) {
textElement.style.display = 'none';
textElement.offsetHeight; // Force reflow
textElement.style.display = '';
}
}
});
});
if (quickBuyDrawerElement) {
const submitButtonTexts = quickBuyDrawerElement.querySelectorAll('button[type="submit"] span.loader-button__text');
submitButtonTexts.forEach(textElement => {
// iOS Safari fix: Use innerHTML instead of textContent for better rendering
const newText = selectedType === 'subscription' ? 'Subscribe' : 'Add to cart';
if (textElement.innerHTML !== newText) {
textElement.innerHTML = newText;
// Force reflow on iOS Safari to ensure text update is rendered
if (/iPad|iPhone|iPod/.test(navigator.userAgent)) {
textElement.style.display = 'none';
textElement.offsetHeight; // Force reflow
textElement.style.display = '';
}
}
});
}
if (quickBuyPopoverElement) {
const submitButtonTexts = quickBuyPopoverElement.querySelectorAll('button[type="submit"] span.loader-button__text');
submitButtonTexts.forEach(textElement => {
// iOS Safari fix: Use innerHTML instead of textContent for better rendering
const newText = selectedType === 'subscription' ? 'Subscribe' : 'Add to cart';
if (textElement.innerHTML !== newText) {
textElement.innerHTML = newText;
// Force reflow on iOS Safari to ensure text update is rendered
if (/iPad|iPhone|iPod/.test(navigator.userAgent)) {
textElement.style.display = 'none';
textElement.offsetHeight; // Force reflow
textElement.style.display = '';
}
}
});
}
if (productStickyElement) {
const submitButtonTexts = productStickyElement.querySelectorAll('button[type="submit"] span.loader-button__text');
submitButtonTexts.forEach(textElement => {
// iOS Safari fix: Use innerHTML instead of textContent for better rendering
const newText = selectedType === 'subscription' ? 'Subscribe' : 'Add to cart';
if (textElement.innerHTML !== newText) {
textElement.innerHTML = newText;
// Force reflow on iOS Safari to ensure text update is rendered
if (/iPad|iPhone|iPod/.test(navigator.userAgent)) {
textElement.style.display = 'none';
textElement.offsetHeight; // Force reflow
textElement.style.display = '';
}
}
});
}
// If switching to subscription, select the first option
if (selectedType === 'subscription') {
subscriptionSelect.selectedIndex = 0;
// get form inside each div with attribute data-block-type="buy-buttons" and add hidden input with name "selling_plan" and value of the selected selling plan
productBuyButtons.forEach(buyButton => {
const form = buyButton.querySelector('form');
// get input with name "selling_plan" and set value to the selected selling plan
const sellingPlanInput = form.querySelector('input[name="selling_plan"]');
sellingPlanInput.value = subscriptionSelect.value;
// get button Submit Text
const submitText = form.querySelector('button[type="submit"]');
const submitTextValue = submitText.querySelector('span.loader-button__text');
// change submit text to "Set up auto-ship"
// iOS Safari fix: Use innerHTML instead of textContent for better rendering
if (submitTextValue.innerHTML !== 'Subscribe') {
submitTextValue.innerHTML = 'Subscribe';
// Force reflow on iOS Safari to ensure text update is rendered
if (/iPad|iPhone|iPod/.test(navigator.userAgent)) {
submitTextValue.style.display = 'none';
submitTextValue.offsetHeight; // Force reflow
submitTextValue.style.display = '';
}
}
});
} else {
// get form inside each div with attribute data-block-type="buy-buttons" and add hidden input with name "selling_plan" and value of the selected selling plan
productBuyButtons.forEach(buyButton => {
const form = buyButton.querySelector('form');
// get input with name "selling_plan" and set value to the selected selling plan
const sellingPlanInput = form.querySelector('input[name="selling_plan"]');
sellingPlanInput.value = '';
// get button Submit Text
const submitText = form.querySelector('button[type="submit"]');
const submitTextValue = submitText.querySelector('span.loader-button__text');
// change submit text to "Set up auto-ship"
// iOS Safari fix: Use innerHTML instead of textContent for better rendering
if (submitTextValue.innerHTML !== 'Add to cart') {
submitTextValue.innerHTML = 'Add to cart';
// Force reflow on iOS Safari to ensure text update is rendered
if (/iPad|iPhone|iPod/.test(navigator.userAgent)) {
submitTextValue.style.display = 'none';
submitTextValue.offsetHeight; // Force reflow
submitTextValue.style.display = '';
}
}
});
}
});
});
// Add event listener for subscription plan selection
const subscriptionSelect = formWrapper.querySelector('.subscription-select');
subscriptionSelect.addEventListener('change', (event) => {
// get form inside each div with attribute data-block-type="buy-buttons" and add hidden input with name "selling_plan" and value of the selected selling plan
productBuyButtons.forEach(buyButton => {
const form = buyButton.querySelector('form');
// get input with name "selling_plan" and set value to the selected selling plan
const sellingPlanInput = form.querySelector('input[name="selling_plan"]');
sellingPlanInput.value = subscriptionSelect.value;
});
});
// Initialize the form with subscription selected
productBuyButtons.forEach(buyButton => {
const form = buyButton.querySelector('form');
const sellingPlanInput = form.querySelector('input[name="selling_plan"]');
sellingPlanInput.value = subscriptionSelect.value;
});
}
}
};
window.customElements.define("subscription-form", SubscriptionForm);
// Custom Subscription Form End
This article is intended for med-exp Shopify Specialist Engineers and does not cover all details related to adding subscriptions to the page form block or customizing styles, as these aspects can be adjusted based on individual theme requirements. For further assistance, a Shopify partner invitation can be sent to [email protected].
