I want a solution for product variant id

import { Component } from '/component';

import { VariantSelectedEvent, VariantUpdateEvent } from '/events';

import { morph } from '/morph';

import { requestYieldCallback, getViewParameterValue } from '/utilities';

/**

* @typedeftypedeftypedeftypedeftypedeftypedeftypedeftypedef {object} VariantPickerRefs

*  {HTMLFieldSetElement[]} fieldsets – The fieldset elements.

*/

/**

* A custom element that manages a v@t@templatemplater@templateant p@templatecker.@template

*

* @template {import('/component').Refs}@extends[TRefs@extendsVariant@extendsickerRefs]

* @extends Component

*/

export default class VariantPicker extends Component {

/**  {string | undefined} */

#pendingRequestUrl;

/**  {AbortController | undefined} */

#abortController;

/**  {number[][]} */

#checkedIndices = [];

/**  {HTMLInputElement[][]} */

#radios = [];

connectedCallback() {

super.connectedCallback();

const fieldsets = /**  {HTMLFieldSetElement[]} */ (this.refs.fieldsets || []);

fieldsets.forEach((fieldset) => {

const radios = Array.from(fieldset?.querySelectorAll('input') ?? []);

this.#radios.push(radios);

const initialCheckedIndex = radios.findIndex((radio) => radio.dataset.currentChecked === 'true');

if (initialCheckedIndex !== -1) {

this.#checkedIndices.push([initialCheckedIndex]);

}

});

this.addEventListener('change', this.variantChanged.bind(this));

}

/**

* Handles the variant change event.

*  {Event} event - The variant change event.

*/

variantChanged(event) {

if (!(event.target instanceof HTMLElement)) return;

const selectedOption =

event.target instanceof HTMLSelectElement ? event.target.options[event.target.selectedIndex] : event.target;

if (!selectedOption) return;

this.updateSelectedOption(event.target);

this.dispatchEvent(new VariantSelectedEvent({ id: selectedOption.dataset.optionValueId ?? '' }));

const isOnProductPage =

this.dataset.templateProductMatch === 'true' &&

!event.target.closest('product-card') &&

!event.target.closest('quick-add-dialog');

// Morph the entire main content for combined listings child products, because changing the product

// might also change other sections depending on recommendations, metafields, etc.

const currentUrl = this.dataset.productUrl?.split('?')[0];

const newUrl = selectedOption.dataset.connectedProductUrl;

const loadsNewProduct = isOnProductPage && !!newUrl && newUrl !== currentUrl;

this.fetchUpdatedSection(this.buildRequestUrl(selectedOption), loadsNewProduct);

const url = new URL(window.location.href);

const variantId = selectedOption.dataset.variantId || null;

if (isOnProductPage) {

if (variantId) {

url.searchParams.set('variant', variantId);

} else {

url.searchParams.delete('variant');

}

}

// Change the path if the option is connected to another product via combined listing.

if (loadsNewProduct) {

url.pathname = newUrl;

}

if (url.href !== window.location.href) {

requestYieldCallback(() => {

history.replaceState({}, '', url.toString());

});

}

}

/**

* Updates the selected option.

*  {string | Element} target - The target element.

*/

updateSelectedOption(target) {

if (typeof target === 'string') {

const targetElement = this.querySelector(`[data-option-value-id="${target}"]`);

if (!targetElement) throw new Error('Target element not found');

target = targetElement;

}

if (target instanceof HTMLInputElement) {

const fieldsetIndex = Number.parseInt(target.dataset.fieldsetIndex || '');

const inputIndex = Number.parseInt(target.dataset.inputIndex || '');

if (!Number.isNaN(fieldsetIndex) && !Number.isNaN(inputIndex)) {

const fieldsets = /**  {HTMLFieldSetElement[]} */ (this.refs.fieldsets || []);

const fieldset = fieldsets[fieldsetIndex];

const checkedIndices = this.#checkedIndices[fieldsetIndex];

const radios = this.#radios[fieldsetIndex];

if (radios && checkedIndices && fieldset) {

// Clear previous checked states

const [currentIndex, previousIndex] = checkedIndices;

if (currentIndex !== undefined && radios[currentIndex]) {

radios[currentIndex].dataset.previousChecked = 'false';

}

if (previousIndex !== undefined && radios[previousIndex]) {

radios[previousIndex].dataset.previousChecked = 'false';

}

// Update checked indices array - keep only the last 2 selections

checkedIndices.unshift(inputIndex);

checkedIndices.length = Math.min(checkedIndices.length, 2);

// Update the new states

const newCurrentIndex = checkedIndices[0]; // This is always inputIndex

const newPreviousIndex = checkedIndices[1]; // This might be undefined

// newCurrentIndex is guaranteed to exist since we just added it

if (newCurrentIndex !== undefined && radios[newCurrentIndex]) {

radios[newCurrentIndex].dataset.currentChecked = 'true';

fieldset.style.setProperty(

'--pill-width-current',

`${radios[newCurrentIndex].parentElement?.offsetWidth || 0}px`

);

}

if (newPreviousIndex !== undefined && radios[newPreviousIndex]) {

radios[newPreviousIndex].dataset.previousChecked = 'true';

radios[newPreviousIndex].dataset.currentChecked = 'false';

fieldset.style.setProperty(

'--pill-width-previous',

`${radios[newPreviousIndex].parentElement?.offsetWidth || 0}px`

);

}

}

}

target.checked = true;

}

if (target instanceof HTMLSelectElement) {

const newValue = target.value;

const newSelectedOption = Array.from(target.options).find((option) => option.value === newValue);

if (!newSelectedOption) throw new Error('Option not found');

for (const option of target.options) {

option.removeAttribute('selected');

}

newSelectedOption.setAttribute('selected', 'selected');

}

}

/**

* Builds the request URL.

*  {HTMLElement} selectedOption - The selected option.

*  {string | null} [source] - The source.

*  {string[]} [sourceSelectedOptionsValues] - The source selected options values.

*  {string} The request URL.

*/

buildRequestUrl(selectedOption, source = null, sourceSelectedOptionsValues = []) {

// this productUrl and pendingRequestUrl will be useful for the support of combined listing. It is used when a user changes variant quickly and those products are using separate URLs (combined listing).

// We create a new URL and abort the previous fetch request if it's still pending.

let productUrl = selectedOption.dataset.connectedProductUrl || this.#pendingRequestUrl || this.dataset.productUrl;

this.#pendingRequestUrl = productUrl;

const params = [];

const viewParamValue = getViewParameterValue();

// preserve view parameter, if it exists, for alternative product view testing

if (viewParamValue) params.push(`view=${viewParamValue}`);

if (this.selectedOptionsValues.length && !source) {

params.push(`option_values=${this.selectedOptionsValues.join(',')}`);

} else if (source === 'product-card') {

if (this.selectedOptionsValues.length) {

params.push(`option_values=${sourceSelectedOptionsValues.join(',')}`);

} else {

params.push(`option_values=${selectedOption.dataset.optionValueId}`);

}

}

// If variant-picker is a child of quick-add-component or swatches-variant-picker-component, we need to append section_id=section-rendering-product-card to the URL

if (this.closest('quick-add-component') || this.closest('swatches-variant-picker-component')) {

if (productUrl?.includes('?')) {

productUrl = productUrl.split('?')[0];

}

return `${productUrl}?section_id=section-rendering-product-card&${params.join('&')}`;

}

return `${productUrl}?${params.join('&')}`;

}

/**

* Fetches the updated section.

*  {string} requestUrl - The request URL.

*  {boolean} shouldMorphMain - If the entire main content should be morphed. By default, only the variant picker is morphed.

*/

fetchUpdatedSection(requestUrl, shouldMorphMain = false) {

// We use this to abort the previous fetch request if it's still pending.

this.#abortController?.abort();

this.#abortController = new AbortController();

fetch(requestUrl, { signal: this.#abortController.signal })

.then((response) => response.text())

.then((responseText) => {

this.#pendingRequestUrl = undefined;

const html = new DOMParser().parseFromString(responseText, 'text/html');

// Defer is only useful for the initial rendering of the page. Remove it here.

html.querySelector('overflow-list[defer]')?.removeAttribute('defer');

const textContent = html.querySelector(`variant-picker script[type="application/json"]`)?.textContent;

if (!textContent) return;

if (shouldMorphMain) {

this.updateMain(html);

} else {

const newProduct = this.updateVariantPicker(html);

// We grab the variant @typedefataset.productIdbject from the response and dispatch an event with it.

if (this.selectedOptionId) {

this.dispatchEvent(

new VariantUpdateEvent(JSON.pa@typedefataset.productIdse(textContent), this.selectedOptionId, {

html,

product@typedefd: this.@typedefataset.productId ?? '',

newProduct,

})

);

}

}

})

.catch((error) => {

if (error.name === 'AbortError') {

console.warn('Fetch abor@typedefed by user');

} else {

cons@typedefle.error(error);

}

});

}

/**

* @typedef {Object} NewProduct

*  {string} id

*  {string} url

*/

/**

* Re-renders the variant picker.

*  {Document} newHtml - The new HTML.

*  {NewProduct | undefined} Information about the new product if it has changed, otherwise undefined.

*/

updateVariantPicker(newHtml) {

/**  {NewProduct | undefined} */

let newProduct;

const newVariantPickerSource = newHtml.querySelector(this.tagName.toLowerCase());

if (!newVariantPickerSource) {

throw new Error('No new variant picker source found');

}

// For combined listings, the product might have changed, so update the related data attribute.

if (newVariantPickerSource instanceof HTMLElement) {

const newProductId = newVariantPickerSource.dataset.productId;

const newProductUrl = newVariantPickerSource.dataset.productUrl;

if (newProductId && newProductUrl && this.dataset.productId !== newProductId) {

newProduct = { id: newProductId, url: newProductUrl };

}

this.dataset.productId = newProductId;

this.dataset.productUrl = newProductUrl;

}

morph(this, newVariantPickerSource);

return newProduct;

}

/**

* Re-renders the entire main content.

*  {Document} newHtml - The new HTML.

*/

updateMain(newHtml) {

const main = document.querySelector('main');

const newMain = newHtml.querySelector('main');

if (!main || !newMain) {

throw new Error('No new main source found');

}

morph(main, newMain);

}

/**

* Gets the selected option.

*  {HTMLInputElement | HTMLOptionElement | undefined} The selected option.

*/

get selectedOption() {

const selectedOption = this.querySelector('select option[selected], fieldset input:checked');

if (!(selectedOption instanceof HTMLInputElement || selectedOption instanceof HTMLOptionElement)) {

return undefined;

}

return selectedOption;

}

/**

* Gets the selected option ID.

*  {string | undefined} The selected option ID.

*/

get selectedOptionId() {

const { selectedOption } = this;

if (!selectedOption) return undefined;

const { optionValueId } = selectedOption.dataset;

if (!optionValueId) {

throw new Error('No option value ID found');

}

return optionValueId;

}

/**

* Gets the selected options values.

*  {string[]} The selected options values.

*/

get selectedOptionsValues() {

/**  HTMLElement[] */

const selectedOptions = Array.from(this.querySelectorAll('select option[selected], fieldset input:checked'));

return selectedOptions.map((option) => {

const { optionValueId } = option.dataset;

if (!optionValueId) throw new Error('No option value ID found');

return optionValueId;

});

}

}

if (!customElements.get('variant-picker')) {

customElements.define('variant-picker', VariantPicker);

}

This is my product-variant.js code from Horizon theme.On my product page it is the theme defualt property where it is taking variant as"defualt title" and variant parameter is showing on url.

I want only param in the url should show only when the real variant is added.

Hey @thewebsitewebmaster
The variant parameter is being added even for products with Shopify’s Default Title variant. It should only be added when the product has real variants (e.g., Size, Color). Add a condition before setting the URL:
Replace the URL update logic with something like:

const url = new URL(window.location.href);
const variantId = selectedOption.dataset.variantId || null;
const variantTitle = selectedOption.dataset.variantTitle || '';
const hasRealVariants = variantTitle !== 'Default Title';

if (isOnProductPage) {
  if (variantId && hasRealVariants) {
    url.searchParams.set('variant', variantId);
  } else {
    url.searchParams.delete('variant');
  }
}

Make sure data-has-only-default-variant="{{ prodcut.has_only_default_variant }} is added to the element. this wil keep the URL Clean for products with only the Default Title variant.

Thanks
Rajat

Hi @thewebsitewebmaster

Replace this code:

const variantId = selectedOption.dataset.variantId || null;

if (isOnProductPage) {
if (variantId) {
url.searchParams.set(‘variant’, variantId);
} else {
url.searchParams.delete(‘variant’);
}
}

With:

const variantId = selectedOption.dataset.variantId || null;
const optionValue = (
selectedOption.value ||
selectedOption.getAttribute(‘value’) ||
selectedOption.textContent ||
‘’
)
.trim()
.toLowerCase();

const isDefaultTitleVariant = optionValue === ‘default title’;

if (isOnProductPage) {
if (variantId && !isDefaultTitleVariant) {
url.searchParams.set(‘variant’, variantId);
} else {
url.searchParams.delete(‘variant’);
}
}

The updated variantChanged() method will be:

variantChanged(event) {
if (!(event.target instanceof HTMLElement)) return;

const selectedOption =
event.target instanceof HTMLSelectElement
? event.target.options[event.target.selectedIndex]
: event.target;

if (!selectedOption) return;

this.updateSelectedOption(event.target);

this.dispatchEvent(
new VariantSelectedEvent({
id: selectedOption.dataset.optionValueId ?? ‘’,
})
);

const isOnProductPage =
this.dataset.templateProductMatch === ‘true’ &&
!event.target.closest(‘product-card’) &&
!event.target.closest(‘quick-add-dialog’);

const currentUrl = this.dataset.productUrl?.split(‘?’)[0];
const newUrl = selectedOption.dataset.connectedProductUrl;

const loadsNewProduct =
isOnProductPage &&
Boolean(newUrl) &&
newUrl !== currentUrl;

this.fetchUpdatedSection(
this.buildRequestUrl(selectedOption),
loadsNewProduct
);

const url = new URL(window.location.href);
const variantId = selectedOption.dataset.variantId || null;

const optionValue = (
selectedOption.value ||
selectedOption.getAttribute(‘value’) ||
selectedOption.textContent ||
‘’
)
.trim()
.toLowerCase();

const isDefaultTitleVariant = optionValue === ‘default title’;

if (isOnProductPage) {
if (variantId && !isDefaultTitleVariant) {
url.searchParams.set(‘variant’, variantId);
} else {
url.searchParams.delete(‘variant’);
}
}

if (loadsNewProduct) {
url.pathname = newUrl;
}

if (url.href !== window.location.href) {
requestYieldCallback(() => {
history.replaceState({}, ‘’, url.toString());
});
}
}

Result:

Single default variant:
/products/product-handle

Real selected variant:
/products/product-handle?variant=123456789

Hey @thewebsitewebmaster

The clean, update-safe fix is a tiny script gated by Liquid, because Liquid already knows when a product has only the default variant through product.has_only_default_variant. Add this to your product section or template (or a Custom Liquid block on the product page)

{% if product.has_only_default_variant %}
  <script>
    (function () {
      const url = new URL(window.location.href);
      if (url.searchParams.has('variant')) {
        url.searchParams.delete('variant');
        history.replaceState({}, '', url.toString());
      }
    })();
  </script>
{% endif %}

That strips the ?variant= param on load only for default-title products and leaves real-variant products untouched, and since those single-variant products have nothing to change, the theme’s JS won’t re-add it. One transparent caveat: this cleans the URL on the product page itself, so if your collection product cards are also linking with ?variant= already baked in, that’s a separate spot to adjust, but for what you described (the param showing on the product page for the default variant) this handles it. Happy to wire it into Horizon cleanly if you’d like.


Hope that helps! If it did, a Like and Marking it as Solution goes a long way and helps others find the fix faster too.

Best,
Moeed