Toyota Tacoma Pre-configured XTR1 Bed Rack
Regular price
$1,549.99 USD
Unit price
/* eslint-disable max-len */
/**
* Dependencies:
* - Custom select component
* - Fetch cache (theme.fetchCache)
* - Quickbuy content transformer (theme.quickBuy.convertToQuickBuyContent)
*
* Required translation strings:
* - noStock
* - noVariant
*/
if (!customElements.get('variant-picker')) {
class VariantPicker extends HTMLElement {
constructor() {
super();
this.quickBuyContainer = this.closest('.js-quickbuy');
this.featuredProductContainer = this.closest('.cc-featured-product');
this.section = this.quickBuyContainer
|| this.featuredProductContainer
|| this.closest('.shopify-section');
this.optionSelectors = this.querySelectorAll(
'.option-selector:not(.option-selector--custom):not(.option-selector--composite-generated)'
);
this.productAvailable = this.classList.contains('variant-picker--product-available');
this.variant = this.getVariantData();
this.compositeSelectionState = {};
if (this.dataset.compositeSelectionState) {
try {
this.compositeSelectionState = JSON.parse(this.dataset.compositeSelectionState) || {};
} catch (error) {
this.compositeSelectionState = {};
}
}
// Track interaction independently for each composite option.
this.compositeInteractionState = {};
if (this.dataset.compositeInteractionState) {
try {
this.compositeInteractionState = JSON.parse(this.dataset.compositeInteractionState) || {};
} catch (error) {
this.compositeInteractionState = {};
}
}
this.compositeUserInteracted = Object.values(this.compositeInteractionState).some(Boolean);
// A saved compositeSelectionState means Shopify has just replaced the
// section. initCompositeSelectors() must not overwrite that state with
// the blank controls it creates before syncCompositeSelectorsFromNative()
// has a chance to restore them.
this.restoringCompositeState = Object.keys(this.compositeSelectionState).length > 0;
this.variantRequestId = 0;
// High-variant support is only used when Shopify's Liquid
// product.variants collection is over its 250-variant limit.
this.highVariantDataLoaded = false;
this.highVariantDataLoading = false;
this.addListeners();
this.updateVariantContent();
if (!this.quickBuyContainer && !this.featuredProductContainer) {
setTimeout(this.applySearchParams.bind(this), 0);
}
this.setAttribute('loaded', '');
}
addListeners() {
// The dynamic variant content used by the theme can be replaced without
// constructing a new , so refresh this NodeList here.
this.optionSelectors = this.querySelectorAll(
'.option-selector:not(.option-selector--custom):not(.option-selector--composite-generated)'
);
this.initCompositeSelectors();
// Do not change the normal v13 path for products with <=250 variants.
// Larger products are augmented after the existing controls are built.
this.loadHighVariantDataIfNeeded();
// Composite selectors are an alternate UI and must start unselected on
// the initial page load. When Shopify replaces the picker after a real
// composite selection, the selection state is carried in data attributes;
// in that case we MUST NOT clear the newly inserted native selector.
if (!this.compositeInitialised) {
this.compositeInitialised = true;
if (!Object.values(this.compositeInteractionState).some(Boolean)) {
this.clearAllCompositeSelections();
}
}
this.boundHandleVariantChange = this.handleVariantChange.bind(this);
this.addEventListener('change', this.boundHandleVariantChange);
this.boundHandleLabelMouseEnter = this.handleLabelMouseEnter.bind(this);
this.querySelectorAll('.opt-label, .custom-select__option').forEach((el) => {
el.addEventListener('mouseenter', this.boundHandleLabelMouseEnter);
el.addEventListener('touchstart', this.boundHandleLabelMouseEnter, { passive: true, once: true });
el.addEventListener('mouseleave', VariantPicker.handleLabelMouseLeave);
});
}
/**
* Builds the additional human-friendly dropdowns for composite Shopify
* option values. The real Shopify selector remains in the DOM but hidden.
*
* A composite option looks like:
* Name: Make--Model--Bed Length
* Value: Chevy--Silverado 1500--6'
*
* The generated controls are only a UI layer. When all component controls
* are selected, the exact original Shopify value is selected on the
* hidden custom-select, allowing the rest of the theme to work normally.
*/
/**
* Augment the existing v13 variant matrix for products with more than
* Shopify's 250-variant Liquid limit.
*
* The original Liquid JSON remains the initial data source. This method
* only runs for >250 variants, fetches the complete variant connection,
* appends missing variants, and then re-runs the existing v13 filtering.
*/
async loadHighVariantDataIfNeeded() {
const variantCount = Number(this.dataset.variantCount || 0);
if (!Number.isFinite(variantCount) || variantCount <= 250) return;
if (this.highVariantDataLoaded || this.highVariantDataLoading) return;
const handle = this.dataset.productHandle || '';
if (!handle) {
console.warn('High-variant composite picker: product handle is missing.');
return;
}
this.highVariantDataLoading = true;
try {
const query = `
query ProductVariants($handle: String!, $cursor: String) {
productByHandle(handle: $handle) {
variants(first: 250, after: $cursor) {
nodes {
id
availableForSale
selectedOptions {
name
value
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`;
const allVariants = [];
let cursor = null;
let hasNextPage = true;
while (hasNextPage) {
const response = await fetch('/api/2026-07/graphql.json', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
query,
variables: { handle, cursor }
})
});
if (!response.ok) {
throw new Error(`Storefront API returned HTTP ${response.status}`);
}
const payload = await response.json();
if (payload.errors?.length) {
throw new Error(payload.errors.map((error) => error.message).join('; '));
}
const connection = payload.data?.productByHandle?.variants;
if (!connection) {
throw new Error('Storefront API did not return product variants.');
}
allVariants.push(...(connection.nodes || []));
hasNextPage = Boolean(connection.pageInfo?.hasNextPage);
cursor = connection.pageInfo?.endCursor || null;
if (hasNextPage && !cursor) {
throw new Error('Storefront API returned another page without a cursor.');
}
}
if (!allVariants.length) {
throw new Error('Storefront API returned no variants.');
}
const completeVariants = allVariants.map((variant) => ({
id: String(variant.id || '').split('/').pop(),
available: Boolean(variant.availableForSale),
options: (variant.selectedOptions || []).map((option) => option.value)
}));
const existingIds = new Set(
(this.compositeVariants || []).map((variant) => String(variant.id))
);
const additionalVariants = completeVariants.filter(
(variant) => variant.id && !existingIds.has(variant.id)
);
this.compositeVariants = [
...(this.compositeVariants || []),
...additionalVariants
];
this.highVariantDataLoaded = true;
// Reuse the existing v13 filtering/selection logic.
this.querySelectorAll(
'.option-selector[data-composite-option="true"]'
).forEach((nativeSelector) => {
this.updateCompositeSelectorsFor(nativeSelector);
});
this.autoSelectForcedCompositeOptions();
} catch (error) {
// Keep the original v13 first-250 behavior if the API is unavailable.
console.warn(
'Unable to load variants beyond Shopify Liquid’s 250-variant limit for the composite picker.',
error
);
} finally {
this.highVariantDataLoading = false;
}
}
initCompositeSelectors() {
const variantDataElement = this.querySelector('[data-composite-variant-data]');
if (!variantDataElement) return;
let variants;
try {
variants = JSON.parse(variantDataElement.textContent);
} catch (error) {
console.warn('Unable to parse composite variant data.', error);
return;
}
this.compositeVariants = Array.isArray(variants) ? variants : [];
// Style invalid composite choices without affecting the theme's native
// custom-select styling. The invalid choices remain in the list so the
// customer can see the complete option set.
if (!this.querySelector('style[data-composite-option-styles]')) {
const style = document.createElement('style');
style.dataset.compositeOptionStyles = 'true';
style.textContent = `
.composite-option-select option.composite-option-invalid {
color: #b85c5c;
background-color: #fdeaea;
}
`;
this.appendChild(style);
}
this.querySelectorAll('.option-selector[data-composite-option="true"]').forEach((nativeSelector) => {
if (nativeSelector.dataset.compositeInitialized === 'true') return;
const delimiter = nativeSelector.dataset.compositeDelimiter || '--';
const nativeOptionName = nativeSelector.dataset.option || '';
const firstNativeValue = nativeSelector.querySelector('.js-option[data-value]')?.dataset.value || '';
if (!firstNativeValue) return;
// The Liquid template uses -- in values even when the abbreviated
// Year-Make-Model name uses single hyphens.
const partNames = nativeOptionName.includes('--')
? nativeOptionName.split('--')
: nativeOptionName.split('-');
const nativeOptions = Array.from(
nativeSelector.querySelectorAll('.js-option[data-value]:not([data-value=""])')
).map((option) => ({
value: option.dataset.value,
valueId: option.dataset.valueId,
parts: option.dataset.value.split('--')
}));
if (!partNames.length || nativeOptions.some((option) => option.parts.length !== partNames.length)) {
return;
}
const compositeContainer = document.createElement('div');
compositeContainer.className = 'composite-option-pickers';
compositeContainer.dataset.compositeFor = nativeSelector.dataset.index;
partNames.forEach((partName, partIndex) => {
const wrapper = document.createElement('div');
wrapper.className = 'option-selector option-selector--composite-generated';
wrapper.dataset.compositeGenerated = 'true';
wrapper.dataset.compositeIndex = nativeSelector.dataset.index;
wrapper.dataset.compositePartIndex = partIndex;
const label = document.createElement('label');
label.className = 'label';
label.textContent = partName;
const select = document.createElement('select');
select.className = 'select composite-option-select custom-select__btn input items-center';
select.dataset.compositeIndex = nativeSelector.dataset.index;
select.dataset.compositePartIndex = partIndex;
select.setAttribute('aria-label', partName);
const placeholder = document.createElement('option');
placeholder.value = '';
placeholder.textContent = this.dataset.placeholderText || 'Please select';
select.appendChild(placeholder);
wrapper.appendChild(label);
wrapper.appendChild(select);
compositeContainer.appendChild(wrapper);
});
nativeSelector.after(compositeContainer);
nativeSelector.classList.add('option-selector--composite-native');
nativeSelector.setAttribute('aria-hidden', 'true');
nativeSelector.style.display = 'none';
nativeSelector.dataset.compositeInitialized = 'true';
nativeSelector.querySelectorAll('.js-option').forEach((option) => {
option.dataset.compositeOriginalValue = option.dataset.value || '';
});
this.updateCompositeSelectorsFor(nativeSelector);
});
}
/**
* Reset every generated composite selector and its hidden Shopify selector.
* This is deliberately used only during initial setup; after that,
* selections are controlled by the customer.
*/
clearAllCompositeSelections() {
this.querySelectorAll('.option-selector[data-composite-option="true"]').forEach((nativeSelector) => {
const container = this.querySelector(
`.composite-option-pickers[data-composite-for="${CSS.escape(nativeSelector.dataset.index)}"]`
);
if (container) {
container.querySelectorAll('.composite-option-select').forEach((select) => {
select.value = '';
});
}
this.compositeSelectionState[nativeSelector.dataset.index] = [];
this.clearCompositeNativeSelection(nativeSelector);
this.updateCompositeSelectorsFor(nativeSelector);
});
}
/**
* Returns the component parts for a Shopify option value.
*/
getCompositeParts(nativeValue, delimiter) {
// Shopify values always use the double-hyphen delimiter. The delimiter
// argument is retained so the generated control can document which
// name syntax activated the feature.
return String(nativeValue || '').split('--');
}
/**
* Tests whether a generated component selection matches a native value.
* Year is special: selecting 2005 matches a native value such as
* "2000-2008".
*/
compositePartMatches(partName, selectedValue, nativePart) {
if (!selectedValue) return true;
if (partName.trim().toLowerCase() === 'year') {
const rangeMatch = String(nativePart).trim().match(/^(\d{4})\s*-\s*(\d{4})$/);
if (rangeMatch) {
const year = Number(selectedValue);
const start = Number(rangeMatch[1]);
const end = Number(rangeMatch[2]);
return Number.isInteger(year) && year >= Math.min(start, end) && year <= Math.max(start, end);
}
}
return nativePart === selectedValue;
}
/**
* Year dropdowns may temporarily display the exact Shopify range that was
* selected by the variant refresh. This is presentation-only state; it is
* not a real generated year selection.
*/
isCompositeYearRangePlaceholder(value) {
return typeof value === 'string' && value.startsWith('__native_year_range__:');
}
getCompositeYearRangeFromPlaceholder(value) {
if (!this.isCompositeYearRangePlaceholder(value)) return '';
return value.slice('__native_year_range__:'.length);
}
/**
* Gets the currently selected generated values for one composite option.
*/
getCompositeSelection(nativeSelector) {
const container = this.querySelector(
`.composite-option-pickers[data-composite-for="${CSS.escape(nativeSelector.dataset.index)}"]`
);
if (!container) return [];
return Array.from(container.querySelectorAll('.composite-option-select')).map((select) => select.value || null);
}
/**
* Determines whether a Shopify variant is compatible with the selections
* currently made in the generated controls.
*/
variantMatchesCompositeSelections(variant, selections, compositeSelectorIndex) {
const nativeOptionIndex = Number(compositeSelectorIndex) - 1;
const nativeValue = variant?.options?.[nativeOptionIndex];
if (nativeValue == null) return false;
const nativeParts = this.getCompositeParts(nativeValue, '');
const container = this.querySelector(
`.composite-option-pickers[data-composite-for="${CSS.escape(String(compositeSelectorIndex))}"]`
);
const selects = container ? Array.from(container.querySelectorAll('.composite-option-select')) : [];
return selections.every((selectedValue, partIndex) => {
if (!selectedValue) return true;
const partName = selects[partIndex]?.getAttribute('aria-label') || '';
return this.compositePartMatches(partName, selectedValue, nativeParts[partIndex]);
});
}
/**
* Updates every generated dropdown so it contains only values that can
* still lead to a real Shopify variant.
*/
updateCompositeSelectorsFor(nativeSelector) {
const container = this.querySelector(
`.composite-option-pickers[data-composite-for="${CSS.escape(nativeSelector.dataset.index)}"]`
);
if (!container || !this.compositeVariants) return;
const selects = Array.from(container.querySelectorAll('.composite-option-select'));
const nativeOptions = Array.from(
nativeSelector.querySelectorAll('.js-option[data-value]:not([data-value=""])')
).map((option) => ({
value: option.dataset.value,
parts: this.getCompositeParts(option.dataset.value, nativeSelector.dataset.compositeDelimiter)
}));
const currentSelections = selects.map((select) => {
const value = select.value || null;
// A native year-range placeholder is informational only. It must not
// constrain the sparse-tree filtering as though it were a real year.
return this.isCompositeYearRangePlaceholder(value) ? null : value;
});
selects.forEach((select, partIndex) => {
const selectedOtherParts = currentSelections.slice();
selectedOtherParts[partIndex] = null;
const nativeOptionIndex = Number(nativeSelector.dataset.index) - 1;
const otherNativeSelections = Array.from(this.optionSelectors).map((selector, selectorIndex) => {
if (selector === nativeSelector) return null;
// Ignore hidden Shopify selections for other composite groups. Their
// native values may be Shopify's automatic first variant. The
// generated controls below are the authoritative composite state.
if (selector.dataset.compositeOption === 'true') return null;
if (selector.dataset.selectorType === 'dropdown') {
const selected = selector.querySelector('.custom-select__option[aria-selected="true"]');
return selected && selected.dataset.value !== '' ? selected.dataset.value : null;
}
const selected = selector.querySelector('input:checked');
return selected ? selected.value : null;
});
const possibleNativeValues = nativeOptions.filter((nativeOption) => {
const hasVariant = this.compositeVariants.some((variant) => {
const variantNativeValue = variant?.options?.[nativeOptionIndex];
if (variantNativeValue !== nativeOption.value) return false;
// Respect any ordinary Shopify option selections.
const otherNativeOptionsMatch = otherNativeSelections.every((selectedValue, selectorIndex) => {
if (!selectedValue) return true;
return variant?.options?.[selectorIndex] === selectedValue;
});
if (!otherNativeOptionsMatch) return false;
// Respect generated selections from every composite option,
// including partially selected composites. This is what makes
// the sparse tree work across multiple Shopify options.
return Array.from(
this.querySelectorAll('.option-selector[data-composite-option="true"]')
).every((otherNativeSelector) => {
const otherIndex = Number(otherNativeSelector.dataset.index) - 1;
const otherContainer = this.querySelector(
`.composite-option-pickers[data-composite-for="${CSS.escape(otherNativeSelector.dataset.index)}"]`
);
if (!otherContainer) return true;
const otherSelects = Array.from(
otherContainer.querySelectorAll('.composite-option-select')
);
const selectionsForOtherComposite = otherNativeSelector === nativeSelector
? selectedOtherParts
: this.getCompositeSelection(otherNativeSelector);
const otherNativeValue = variant?.options?.[otherIndex];
if (otherNativeValue == null) return false;
const otherParts = this.getCompositeParts(
otherNativeValue,
otherNativeSelector.dataset.compositeDelimiter
);
return selectionsForOtherComposite.every((selectedValue, componentIndex) => {
if (!selectedValue) return true;
const componentName = otherSelects[componentIndex]?.getAttribute('aria-label') || '';
return this.compositePartMatches(
componentName,
selectedValue,
otherParts[componentIndex]
);
});
});
});
return hasVariant;
});
// Build two complete option sets:
// 1. valid values, based on the current selections
// 2. all values that exist in the product, regardless of the current selections
//
// Valid values are displayed first. Invalid values remain visible in
// the dropdown so the customer can see the complete option tree.
const allValues = new Map();
const validValues = new Map();
const partName = select.getAttribute('aria-label') || '';
const isYear = partName.trim().toLowerCase() === 'year';
const addPartValue = (map, part) => {
if (part == null || part === '') return;
if (isYear) {
const rangeMatch = String(part).trim().match(/^(\d{4})\s*-\s*(\d{4})$/);
if (rangeMatch) {
const start = Number(rangeMatch[1]);
const end = Number(rangeMatch[2]);
for (let year = Math.max(start, end); year >= Math.min(start, end); year -= 1) {
map.set(String(year), String(year));
}
return;
}
}
map.set(part, part);
};
nativeOptions.forEach((nativeOption) => {
addPartValue(allValues, nativeOption.parts[partIndex]);
});
possibleNativeValues.forEach((nativeOption) => {
addPartValue(validValues, nativeOption.parts[partIndex]);
});
const previousValue = select.value;
const previousYearRange = this.isCompositeYearRangePlaceholder(previousValue)
? this.getCompositeYearRangeFromPlaceholder(previousValue)
: '';
select.innerHTML = '';
const placeholder = document.createElement('option');
placeholder.value = '';
placeholder.textContent = this.dataset.placeholderText || 'Please select';
select.appendChild(placeholder);
const validSortedValues = Array.from(validValues.entries());
const invalidSortedValues = Array.from(allValues.entries())
.filter(([value]) => !validValues.has(value));
const sortValues = (a, b) => {
if (isYear) return Number(b[0]) - Number(a[0]);
return a[1].localeCompare(b[1], undefined, {
numeric: true,
sensitivity: 'base'
});
};
validSortedValues.sort(sortValues);
invalidSortedValues.sort(sortValues);
const appendOption = ([value, label], invalid) => {
const option = document.createElement('option');
option.value = value;
option.textContent = label;
if (invalid) {
option.classList.add('composite-option-invalid');
option.dataset.compositeInvalid = 'true';
// Inline styling provides a fallback for browsers that do not
// apply stylesheet rules to native elements.
option.style.color = '#b85c5c';
option.style.backgroundColor = '#fdeaea';
}
select.appendChild(option);
};
validSortedValues.forEach((entry) => appendOption(entry, false));
invalidSortedValues.forEach((entry) => appendOption(entry, true));
// Keep an informational native-range selection visible after a
// rebuild. It is presentation-only and is never marked invalid.
if (isYear && previousYearRange) {
const rangeMatch = previousYearRange.match(/^(\d{4})\s*-\s*(\d{4})$/);
const rangeStillAvailable = rangeMatch && nativeOptions.some((nativeOption) => {
const part = nativeOption.parts[partIndex];
return String(part).trim() === previousYearRange;
});
if (rangeStillAvailable) {
const rangeOption = document.createElement('option');
rangeOption.value = `__native_year_range__:${previousYearRange}`;
rangeOption.textContent = `${previousYearRange} (selected)`;
select.insertBefore(rangeOption, select.options[1] || null);
select.value = rangeOption.value;
return;
}
}
if (previousValue && allValues.has(previousValue)) {
const restoredOption = Array.from(select.options).find(
(option) => option.value === previousValue
);
if (restoredOption && !restoredOption.dataset.compositeInvalid) {
select.value = previousValue;
} else {
select.value = '';
}
} else {
select.value = '';
}
});
// During a Shopify section replacement, the generated controls have not
// been restored from the saved state yet. Do not overwrite that saved
// state with the freshly-created (blank) controls.
if (!this.restoringCompositeState) {
this.compositeSelectionState[nativeSelector.dataset.index] = selects.map(
(select) => select.value || null
);
}
}
/**
* Automatically selects a generated component when filtering leaves
* exactly one real choice. This is intentionally iterative: selecting one
* forced value can reduce another component to a single choice.
*
* Placeholder and informational Year-range options do not count as real
* choices. Only blank generated controls are auto-selected, so an explicit
* customer choice is never overwritten.
*/
autoSelectForcedCompositeOptions() {
if (this.autoSelectingForcedCompositeOptions) return;
this.autoSelectingForcedCompositeOptions = true;
try {
let changed = true;
let safety = 0;
while (changed && safety++ < 50) {
changed = false;
const selectors = Array.from(
this.querySelectorAll('.composite-option-select')
);
for (const select of selectors) {
// Never overwrite an explicit customer selection.
if (select.value) continue;
const realOptions = Array.from(select.options).filter((option) => {
if (!option.value) return false;
if (this.isCompositeYearRangePlaceholder(option.value)) return false;
// Only a genuinely valid option can be auto-selected. Invalid
// (red) options remain visible, but must never count toward the
// number of choices available for the current selection.
if (option.dataset.compositeInvalid === 'true') return false;
return !option.disabled;
});
if (realOptions.length !== 1) continue;
const forcedValue = realOptions[0].value;
select.value = forcedValue;
// Route the automatic selection through the same handler as a
// customer selection so composite state, filtering, and the
// hidden Shopify selector remain synchronized.
select.dispatchEvent(new Event('change', { bubbles: true }));
changed = true;
// Let the resulting change event update all dependent controls
// before looking for the next forced value.
break;
}
}
} finally {
this.autoSelectingForcedCompositeOptions = false;
}
}
/**
* Resets every generated composite selector when the customer explicitly
* chooses an option that is incompatible with the current selections.
* The invalid option itself is not retained: the customer is returned to
* a clean state where every option is available again.
*/
resetAllCompositeGeneratedSelections() {
this.autoSelectingForcedCompositeOptions = true;
try {
this.querySelectorAll('.option-selector[data-composite-option="true"]').forEach((nativeSelector) => {
const container = this.querySelector(
`.composite-option-pickers[data-composite-for="${CSS.escape(nativeSelector.dataset.index)}"]`
);
if (container) {
container.querySelectorAll('.composite-option-select').forEach((select) => {
select.value = '';
});
}
this.compositeSelectionState[nativeSelector.dataset.index] = [];
this.compositeInteractionState[nativeSelector.dataset.index] = false;
this.clearCompositeNativeSelection(nativeSelector);
});
this.compositeUserInteracted = false;
this.dataset.compositeUserInteracted = 'false';
this.dataset.compositeInteractionState = JSON.stringify(this.compositeInteractionState);
this.dataset.compositeSelectionState = JSON.stringify(this.compositeSelectionState);
this.querySelectorAll('.option-selector[data-composite-option="true"]').forEach((nativeSelector) => {
this.updateCompositeSelectorsFor(nativeSelector);
});
} finally {
this.autoSelectingForcedCompositeOptions = false;
}
}
/**
* Handles a generated dropdown change. The hidden Shopify selector is
* deliberately left blank until every generated component is selected.
*/
handleCompositeChange(evt) {
const generatedSelect = evt.target;
const compositeIndex = generatedSelect.dataset.compositeIndex;
const nativeSelector = this.querySelector(
`.option-selector[data-composite-option="true"][data-index="${CSS.escape(compositeIndex)}"]`
);
if (!nativeSelector) return;
// An invalid/red option is intentionally visible, but choosing it means
// the customer wants to change direction. Reset all composite groups so
// every option becomes available again.
const selectedOption = generatedSelect.selectedOptions?.[0];
if (selectedOption?.dataset.compositeInvalid === 'true') {
// The customer is deliberately changing direction. Keep the option
// they just clicked, but clear every OTHER generated component. This
// makes the clicked red option the starting point for the new path
// instead of immediately erasing the customer's click.
const clickedValue = generatedSelect.value || '';
this.autoSelectingForcedCompositeOptions = true;
try {
this.querySelectorAll('.option-selector[data-composite-option="true"]').forEach((selector) => {
const index = selector.dataset.index;
const container = this.querySelector(
`.composite-option-pickers[data-composite-for="${CSS.escape(index)}"]`
);
if (!container) return;
const isClickedGroup = index === compositeIndex;
const groupSelects = Array.from(container.querySelectorAll('.composite-option-select'));
if (isClickedGroup) {
// Keep the clicked component and clear the rest of its group.
groupSelects.forEach((select) => {
if (select !== generatedSelect) select.value = '';
});
this.compositeSelectionState[index] = groupSelects.map((select) => select.value || null);
this.compositeInteractionState[index] = true;
} else {
groupSelects.forEach((select) => {
select.value = '';
});
this.compositeSelectionState[index] = [];
this.compositeInteractionState[index] = false;
this.clearCompositeNativeSelection(selector);
}
});
this.compositeUserInteracted = true;
this.dataset.compositeUserInteracted = 'true';
this.dataset.compositeInteractionState = JSON.stringify(this.compositeInteractionState);
this.dataset.compositeSelectionState = JSON.stringify(this.compositeSelectionState);
// Rebuild using the clicked option as the only active constraint.
this.querySelectorAll('.option-selector[data-composite-option="true"]').forEach((selector) => {
this.updateCompositeSelectorsFor(selector);
});
} finally {
this.autoSelectingForcedCompositeOptions = false;
}
return;
}
this.compositeUserInteracted = true;
this.compositeInteractionState[compositeIndex] = true;
this.dataset.compositeInteractionState = JSON.stringify(this.compositeInteractionState);
this.dataset.compositeUserInteracted = 'true';
const selections = this.getCompositeSelection(nativeSelector).map((value) =>
this.isCompositeYearRangePlaceholder(value) ? null : value
);
// If the customer changes any component while the year dropdown is
// showing Shopify's informational range, the range is no longer a
// concrete customer year selection. The individual year choices remain
// available for an explicit choice.
const yearSelect = Array.from(
this.querySelector(
`.composite-option-pickers[data-composite-for="${CSS.escape(compositeIndex)}"]`
)?.querySelectorAll('.composite-option-select') || []
).find((select) => select.getAttribute('aria-label')?.trim().toLowerCase() === 'year');
if (yearSelect && this.isCompositeYearRangePlaceholder(yearSelect.value)) {
yearSelect.value = '';
}
this.compositeSelectionState[compositeIndex] = selections.slice();
this.dataset.compositeSelectionState = JSON.stringify(this.compositeSelectionState);
// Rebuild every composite group so each dropdown reflects the complete
// current state of the product, not just the group that was changed.
this.querySelectorAll('.option-selector[data-composite-option="true"]').forEach((selector) => {
this.updateCompositeSelectorsFor(selector);
});
// Complete any other generated selectors that have become forced to a
// single valid value as a result of this selection.
this.autoSelectForcedCompositeOptions();
if (selections.some((value) => !value)) {
this.clearCompositeNativeSelection(nativeSelector);
return;
}
const nativeOptions = Array.from(
nativeSelector.querySelectorAll('.js-option[data-value]:not([data-value=""])')
);
const matchingNativeOption = nativeOptions.find((option) => {
const parts = this.getCompositeParts(option.dataset.value, nativeSelector.dataset.compositeDelimiter);
const selects = Array.from(
this.querySelector(
`.composite-option-pickers[data-composite-for="${CSS.escape(compositeIndex)}"]`
).querySelectorAll('.composite-option-select')
);
return selections.every((selectedValue, partIndex) => {
const partName = selects[partIndex].getAttribute('aria-label') || '';
return this.compositePartMatches(partName, selectedValue, parts[partIndex]);
});
});
if (!matchingNativeOption) {
this.clearCompositeNativeSelection(nativeSelector);
return;
}
const customSelect = nativeSelector.querySelector('custom-select');
if (!customSelect) return;
// Mark this specific composite group as intentionally selected.
this.compositeUserInteracted = true;
this.compositeInteractionState[compositeIndex] = true;
this.dataset.compositeInteractionState = JSON.stringify(this.compositeInteractionState);
this.dataset.compositeUserInteracted = 'true';
// If Shopify already has this exact native option selected (which is
// commonly true for the first variant on initial page load),
// selectOption() has nothing to change and therefore does not fire the
// variant-picker change handler. The customer has nevertheless just
// completed a valid selection, so explicitly fire the same change event
// that Shopify's custom-select normally produces.
if (matchingNativeOption.getAttribute('aria-selected') === 'true') {
customSelect.dispatchEvent(new CustomEvent('change', {
bubbles: true,
cancelable: false,
detail: {
variantId: matchingNativeOption.dataset.variantId || '',
productUrl: matchingNativeOption.dataset.productUrl || ''
}
}));
} else {
customSelect.selectOption(matchingNativeOption);
}
}
/**
* Clears the real Shopify selector without starting a variant request.
*/
clearCompositeNativeSelection(nativeSelector) {
const customSelect = nativeSelector.querySelector('custom-select');
if (!customSelect) return;
const placeholder = customSelect.querySelector(
'.custom-select__option[data-value=""]'
);
if (!placeholder || placeholder.getAttribute('aria-selected') === 'true') return;
this.suppressCompositeNativeChange = true;
customSelect.selectOption(placeholder);
}
/**
* Synchronizes generated controls from the current native Shopify value.
* This is used when a variant is loaded directly or after Shopify replaces
* variant-dependent content.
*/
syncCompositeSelectorsFromNative() {
this.querySelectorAll('.option-selector[data-composite-option="true"]').forEach((nativeSelector) => {
const container = this.querySelector(
`.composite-option-pickers[data-composite-for="${CSS.escape(nativeSelector.dataset.index)}"]`
);
if (!container) return;
// Shopify may have already selected the first native variant for
// this group. That is not a customer choice. Only sync this group
// after the customer has interacted with this specific composite.
const compositeIndex = nativeSelector.dataset.index;
if (!this.compositeInteractionState[compositeIndex]) {
container.querySelectorAll('.composite-option-select').forEach((select) => {
select.value = '';
});
this.updateCompositeSelectorsFor(nativeSelector);
return;
}
const selectedNativeOption = nativeSelector.querySelector('.custom-select__option[aria-selected="true"]');
const nativeValue = selectedNativeOption?.dataset.value || '';
const selects = Array.from(container.querySelectorAll('.composite-option-select'));
if (!nativeValue) {
// A blank native selector means there is no Shopify variant to sync
// into the generated controls. Preserve customer selections only
// after they have actually interacted with the composite controls.
if (!this.compositeInteractionState[compositeIndex]) {
selects.forEach((select) => {
select.value = '';
});
}
this.updateCompositeSelectorsFor(nativeSelector);
return;
}
const parts = this.getCompositeParts(nativeValue, nativeSelector.dataset.compositeDelimiter);
selects.forEach((select, partIndex) => {
const partName = select.getAttribute('aria-label') || '';
const storedValue = this.compositeSelectionState[nativeSelector.dataset.index]?.[partIndex] || '';
const currentValue = select.value || '';
// The generated controls are the authoritative presentation state
// after the customer has interacted. Keep them when they still
// describe the selected native value. This is especially important
// for Year, where a generated year (e.g. 2006) maps to a range
// (e.g. 2000-2006) rather than to the literal native value.
if (storedValue) {
if (partName.trim().toLowerCase() === 'year') {
const rangeMatch = String(parts[partIndex] || '').match(/^(\d{4})\s*-\s*(\d{4})$/);
if (rangeMatch) {
const year = Number(storedValue);
const start = Number(rangeMatch[1]);
const end = Number(rangeMatch[2]);
if (year >= Math.min(start, end) && year <= Math.max(start, end)) {
select.value = storedValue;
return;
}
}
} else if (this.compositePartMatches(partName, storedValue, parts[partIndex])) {
select.value = storedValue;
return;
}
}
if (currentValue) {
if (partName.trim().toLowerCase() === 'year') {
const rangeMatch = String(parts[partIndex] || '').match(/^(\d{4})\s*-\s*(\d{4})$/);
if (rangeMatch) {
const year = Number(currentValue);
const start = Number(rangeMatch[1]);
const end = Number(rangeMatch[2]);
if (year >= Math.min(start, end) && year <= Math.max(start, end)) {
select.value = currentValue;
return;
}
}
} else if (this.compositePartMatches(partName, currentValue, parts[partIndex])) {
select.value = currentValue;
return;
}
}
if (partName.trim().toLowerCase() === 'year') {
const nativeYearRange = String(parts[partIndex] || '').trim();
if (/^\d{4}\s*-\s*\d{4}$/.test(nativeYearRange)) {
const rangeOptionValue = `__native_year_range__:${nativeYearRange}`;
select.value = rangeOptionValue;
// updateCompositeSelectorsFor() will create this informational
// option if it remains valid after filtering.
select.dataset.pendingNativeYearRange = nativeYearRange;
return;
}
}
select.value = parts[partIndex] || '';
});
this.updateCompositeSelectorsFor(nativeSelector);
// The rebuild above intentionally occurs before the informational
// range option is installed. Re-apply it now if the native Shopify
// value is a year range and no concrete customer year was retained.
selects.forEach((select, partIndex) => {
if (select.getAttribute('aria-label')?.trim().toLowerCase() !== 'year') return;
const pendingRange = select.dataset.pendingNativeYearRange;
if (!pendingRange) return;
delete select.dataset.pendingNativeYearRange;
const existing = Array.from(select.options).find((option) =>
option.value === `__native_year_range__:${pendingRange}`
);
if (existing) {
select.value = existing.value;
return;
}
const rangeOption = document.createElement('option');
rangeOption.value = `__native_year_range__:${pendingRange}`;
rangeOption.textContent = `${pendingRange} (selected)`;
select.insertBefore(rangeOption, select.options[1] || null);
select.value = rangeOption.value;
});
// The generated controls are now restored. Persist their actual values
// so the next Shopify section replacement receives the complete state
// for every composite group.
this.compositeSelectionState[nativeSelector.dataset.index] = selects.map(
(select) => select.value || null
);
});
this.restoringCompositeState = false;
this.dataset.compositeSelectionState = JSON.stringify(this.compositeSelectionState);
}
removeListeners() {
this.removeEventListener('change', this.boundHandleVariantChange);
this.querySelectorAll('.opt-label, .custom-select__option').forEach((el) => {
el.removeEventListener('mouseenter', this.boundHandleLabelMouseEnter);
el.removeEventListener('touchstart', this.boundHandleLabelMouseEnter);
el.removeEventListener('mouseleave', VariantPicker.handleLabelMouseLeave);
});
}
/**
* Update parts of this component that require JS to display correctly.
*/
updateVariantContent() {
this.initCompositeSelectors();
this.syncCompositeSelectorsFromNative();
this.updateStatusClasses();
this.updateAddToCartButton();
this.updateSwatchLabel();
this.updateAvailability();
}
getProductForm() {
return this.section.querySelector('.js-product-form');
}
/**
* Handles 'mouseenter' events on option labels. Preloads links.
* @param {object} evt - Event object.
*/
handleLabelMouseEnter(evt) {
const label = evt.currentTarget;
if (label.dataset.preloaded) return; // Preload completed
if (label.dataset.preloadTimeout) return; // Preload waiting
if (label.matches('.opt-btn:checked + .opt-label, [aria-selected="true"]')) return; // Already selected
// Build option array if clicked
const input = label.htmlFor ? document.getElementById(label.htmlFor) : label;
const optionIds = this.getSelectedOptionIds();
const inputOptionIndex = Array.from(this.optionSelectors).indexOf(input.closest('.option-selector'));
optionIds[inputOptionIndex] = input.dataset.valueId;
// If all options are selected
if (optionIds.indexOf(null) === -1) {
// Wait to predict click-intent and preload link
label.dataset.preloadTimeout = setTimeout(() => {
label.dataset.preloaded = true;
label.removeAttribute('data-preload-timeout');
const combinedProductUrl = input.dataset.productUrl;
const url = this.constructVariantUrl(
combinedProductUrl,
optionIds,
input.dataset.variantId
);
theme.fetchCache.preload(url.toString());
}, 250);
}
}
/**
* Handles 'mouseleave' events on option labels. Cancels link preload if in/out fast.
* @param {object} evt - Event object.
*/
static handleLabelMouseLeave(evt) {
const label = evt.currentTarget;
clearTimeout(label.dataset.preloadTimeout);
label.removeAttribute('data-preload-timeout');
}
/**
* Create a URL for fetching a product or variant.
* @param {string} combinedProductUrl - URL for a combined product. Optional.
* @param {Array} optionIds - Selected option IDs to include in the URL. Optional.
* @param {string} variantId - Variant ID to use if not a combined product. Optional.
* @returns {URL} Variant URL
*/
constructVariantUrl(combinedProductUrl, optionIds, variantId) {
let url = null;
if (combinedProductUrl) {
url = new URL(combinedProductUrl, window.location.origin);
url.searchParams.set('option_values', optionIds.join(','));
} else {
url = new URL(this.dataset.url, window.location.origin);
}
const sectionIds = [this.dataset.sectionId];
if (!this.quickBuyContainer && !this.featuredProductContainer) {
document.querySelectorAll('.cc-variant-dependent-section [data-section-id]').forEach((e) => {
if (e.closest('.rte')) return;
sectionIds.push(e.dataset.sectionId);
});
}
url.searchParams.set('sections', sectionIds.join(','));
if (combinedProductUrl || !variantId || optionIds.includes(null)) {
url.searchParams.set('option_values', optionIds.join(','));
} else {
url.searchParams.set('variant', variantId);
}
return url;
}
/**
* Handles 'change' events on the variant picker element.
* @param {object} evt - Event object.
*/
handleVariantChange(evt) {
if (this.suppressCompositeNativeChange) {
this.suppressCompositeNativeChange = false;
return;
}
// Composite controls are presentation-only. They must not be treated as
// Shopify option selectors until all of their component values form an
// actual Shopify option value.
if (evt.target.matches('.composite-option-select')) {
this.handleCompositeChange(evt);
return;
}
const selectedOptionIds = this.getSelectedOptionIds();
// Immediately update swatch label
this.updateSwatchLabel();
// Construct new variant URL and fetch it, using theme cache
const combinedProductUrl = evt.target.dataset.productUrl || evt.detail?.productUrl;
const variantId = evt.target.dataset.variantId || evt.detail?.variantId;
const url = this.constructVariantUrl(combinedProductUrl, selectedOptionIds, variantId);
this.variantRequestId += 1;
const currentRequestId = this.variantRequestId;
theme.fetchCache.fetch(url.toString())
.then((responseText) => {
if (currentRequestId !== this.variantRequestId) return;
const responseData = JSON.parse(responseText);
// Update content
Object.entries(responseData).forEach(([section, htmlStr]) => {
const html = new DOMParser().parseFromString(htmlStr, 'text/html');
const sectionId = `shopify-section-${section}`;
let target = this.section;
let newContent = html.getElementById(sectionId);
if (this.section.id !== sectionId) {
if (!this.quickBuyContainer) {
target = document.getElementById(sectionId);
} else {
const quickBuyTemplate = html.getElementById('quick-buy-template')?.content.querySelector('.js-product');
if (quickBuyTemplate) newContent = quickBuyTemplate;
}
}
this.updateContent(target, newContent, combinedProductUrl);
});
// Restore focus
if (!evt.detail?.noFocus) {
document.querySelector(`#${evt.target.id}-button, input#${evt.target.id}`)?.focus();
}
// Update URL and announce changes
const newVariantPicker = this.section.querySelector('variant-picker');
setTimeout(() => newVariantPicker.announceChange(), 10);
});
}
/**
* Announces the current variant after a variant change.
*/
announceChange() {
const selectedOptionIds = this.getSelectedOptionIds();
if (!selectedOptionIds.includes(null)) {
this.updateUrl();
}
this.dispatchEvent(new CustomEvent('on:variant:change', {
bubbles: true,
detail: {
form: this.getProductForm(),
variant: this.variant,
selectedOptions: this.getSelectedOptions()
}
}));
}
/**
* Preserve composite UI state across Shopify's dynamic section refresh.
* Shopify replaces portions of the variant picker after a complete
* selection. The generated selectors are presentation state, so carry
* that state onto the replacement before it is initialized.
*/
preserveCompositeStateOnReplacement(sourcePicker, replacementPicker) {
if (!sourcePicker || !replacementPicker) return;
if (sourcePicker.compositeUserInteracted) {
replacementPicker.dataset.compositeUserInteracted = 'true';
}
if (sourcePicker.compositeInteractionState) {
replacementPicker.dataset.compositeInteractionState = JSON.stringify(
sourcePicker.compositeInteractionState
);
}
if (sourcePicker.compositeSelectionState) {
replacementPicker.dataset.compositeSelectionState = JSON.stringify(
sourcePicker.compositeSelectionState
);
}
}
/**
* Replace content when the variant or product changes.
* @param {Element} target - Target container for replacing content within.
* @param {document} newContent - New content containing elements to use.
* @param {boolean} productChange - Product is changing.
*/
updateContent(target, newContent, productChange) {
// If something's not right, do nothing
if (!target || !newContent) return;
// Preserve generated composite-picker state before Shopify replaces DOM.
// `target` is normally the Shopify section, not the variant-picker itself,
// so looking upward from target cannot reliably find the current picker.
// `this` is the picker that initiated this request and is therefore the
// authoritative source of the customer's composite selections.
const sourcePicker = this;
// Preselection - only update certain option value attributes
if (this.getSelectedOptions().indexOf(null) !== -1 && !productChange) {
target.querySelectorAll('.js-option[data-value-id]').forEach((input) => {
const newInput = newContent.querySelector(`.js-option[data-value-id="${input.dataset.valueId}"]`);
input.className = newInput.className;
if (newInput.dataset.variantId) {
input.dataset.variantId = newInput.dataset.variantId;
} else {
delete input.dataset.variantId;
}
if (input.classList.contains('custom-select__option')) {
input.innerHTML = newInput.innerHTML;
}
});
this.updateStatusClasses();
return;
}
let replaceSelector = '[data-dynamic-variant-content]';
if (productChange) {
replaceSelector += ', [data-dynamic-product-content]';
}
const toReplaceList = target.querySelectorAll(replaceSelector);
toReplaceList.forEach((toReplace) => {
const replaceWith = toReplace.dataset.dynamicVariantContent
? newContent.querySelector(`[data-dynamic-variant-content="${CSS.escape(toReplace.dataset.dynamicVariantContent)}"]`)
: newContent.querySelector(`[data-dynamic-product-content="${CSS.escape(toReplace.dataset.dynamicProductContent)}"]`);
if (!replaceWith) return;
// Dispatch 'on:variant:before-replace-element' event before replacing element
document.dispatchEvent(new CustomEvent('on:variant:before-replace-element', {
bubbles: true,
detail: {
toReplace,
replaceWith
}
}));
// If in quick buy, content needs mutating
if (this.quickBuyContainer && theme.quickBuy) {
theme.quickBuy.convertToQuickBuyContent(
replaceWith,
this.quickBuyContainer.dataset.productUrl
);
}
// Transfer basic input values
toReplace.querySelectorAll('input[id]:not([type="hidden"]), select[id]').forEach((sourceInput) => {
if (sourceInput.matches('.custom-select__native')) {
const customSelectId = sourceInput.id.replace('-native', '');
const targetInput = replaceWith.querySelector(`#${CSS.escape(customSelectId)}`);
const option = targetInput.querySelector(`.js-option[data-value="${CSS.escape(sourceInput.value)}"]`);
setTimeout(() => {
targetInput.selectOption(option);
}, 0);
} else {
const targetInput = replaceWith.querySelector(`#${CSS.escape(sourceInput.id)}`);
if (!targetInput) return;
if (sourceInput.type === 'radio' || sourceInput.type === 'checkbox') {
targetInput.toggleAttribute('checked', sourceInput.checked);
} else {
targetInput.value = sourceInput.value;
}
}
});
// Replace content
const isIdInput = replaceWith.matches('input[name="id"]');
if (isIdInput) {
// Update ID input - preserving listeners
[...replaceWith.attributes].forEach(
(attr) => toReplace.setAttribute(attr.name, attr.value)
);
toReplace.value = replaceWith.value;
// Defer change event until all forms are accurate
setTimeout(() => {
toReplace.dispatchEvent(new Event('change', { bubbles: true }));
}, 0);
} else {
// Remember toReplace becomes detached. Preserve composite UI state
// on the replacement because Shopify may create a fresh variant-picker
// instance after a complete variant is selected.
const replacementPicker = replaceWith.matches('variant-picker')
? replaceWith
: replaceWith.querySelector('variant-picker');
if (sourcePicker && replacementPicker) {
this.preserveCompositeStateOnReplacement(sourcePicker, replacementPicker);
}
toReplace.replaceWith(replaceWith);
}
// Do not mistake input value changes for variant changes - remove listeners
replaceWith.querySelectorAll('variant-picker').forEach((el) => el.removeListeners());
// Set values of any unselected elements again
// A fetched variant may not appear selected
const selectedOptionIds = this.getSelectedOptionIds();
selectedOptionIds.forEach((optionId) => {
const toSelect = replaceWith.querySelector(`.option-selector .custom-select__option[data-value-id="${optionId}"]:not([aria-selected="true"])`);
if (toSelect) {
setTimeout(() => {
toSelect.closest('custom-select').selectOption(toSelect);
}, 0);
}
});
// Update variant picker after potential changes are applied
setTimeout(() => {
replaceWith.querySelectorAll('variant-picker').forEach((el) => {
el.addListeners();
el.updateVariantContent();
});
}, 10);
});
}
/**
* Set status classes to help with styling.
*/
updateStatusClasses() {
const selectedOpts = this.getSelectedOptionIds();
const nullCount = selectedOpts.filter((x) => x === null).length;
const isSingleNullAtEnd = nullCount === 1 && selectedOpts[selectedOpts.length - 1] === null;
this.classList.toggle('variant-picker--preselection', nullCount > 0);
this.classList.toggle('variant-picker--pre-last-selection', nullCount > 0 && !isSingleNullAtEnd);
}
/**
* Copies the selected value into a swatch label. (Required for pre-selection.)
*/
updateSwatchLabel() {
this.querySelectorAll('.option-selector:has(.js-color-text)').forEach((option) => {
const label = option.querySelector('.js-color-text');
const input = option.querySelector('.js-option:is(:checked, [aria-selected="true"])');
label.textContent = input ? input.value : '';
});
}
/**
* Updates the availability status in option selectors.
*/
updateAvailability() {
if (this.dataset.availability === 'prune') {
const toChange = this.querySelectorAll('.js-option.is-unavailable:is(:checked, [aria-selected="true"])');
toChange.forEach((selected) => {
const toSelect = selected.closest('.option-selector').querySelector('.js-option:not(.is-unavailable, [data-value=""])');
if (toSelect) {
setTimeout(() => {
if (toSelect.closest('custom-select')) {
toSelect.closest('custom-select').selectOption(toSelect);
} else {
toSelect.click();
}
}, 20); // Trigger after any updateContent logic has occurred
}
});
}
this.querySelectorAll('.option-selector[data-composite-option="true"]').forEach((nativeSelector) => {
this.updateCompositeSelectorsFor(nativeSelector);
});
// Filtering may have reduced one or more generated selectors to a
// single valid choice. Let the normal composite change handler process
// those forced selections.
this.autoSelectForcedCompositeOptions();
}
/**
* Updates the "Add to Cart" button label and disabled state.
*/
updateAddToCartButton() {
const productForm = this.getProductForm();
if (!productForm) return;
this.addBtn = productForm.querySelector('[name="add"]');
// Product not available
if (!this.productAvailable) {
this.addBtn.disabled = true;
this.addBtn.textContent = theme.strings.noStock;
return;
}
// Preselection
if (this.getSelectedOptions().indexOf(null) !== -1) {
if (this.addBtn.dataset.preselectionDisabled === 'true') {
this.addBtn.disabled = true;
this.addBtn.textContent = this.addBtn.dataset.preselectionText;
} else {
this.addBtn.disabled = false;
this.addBtn.textContent = this.addBtn.dataset.addToCartText;
}
return;
}
// No variant
if (!this.variant) {
this.addBtn.disabled = true;
this.addBtn.textContent = theme.strings.noVariant;
return;
}
// Sold out
if (!this.variant.available) {
this.addBtn.disabled = true;
this.addBtn.textContent = theme.strings.noStock;
return;
}
// Add to cart
this.addBtn.disabled = false;
this.addBtn.textContent = this.addBtn.dataset.addToCartText;
}
/**
* Updates the url with the selected variant id.
*/
updateUrl() {
if (this.dataset.updateUrl === 'false') return;
const url = this.variant ? `${this.dataset.url}?variant=${this.variant.id}` : this.dataset.url;
window.history.replaceState({ }, '', url);
}
/**
* Gets the variant data for a product.
* @returns {?object}
*/
getVariantData() {
const dataEl = this.section.querySelector('variant-picker [type="application/json"]');
return dataEl ? JSON.parse(dataEl.textContent) : null;
}
/**
* Get the selected values from a list of variant options.
* @returns {Array} Array of selected option values, value is null if not selected.
*/
getSelectedOptions() {
const selectedOptions = [];
this.optionSelectors.forEach((selector) => {
// A composite option is only a real Shopify selection after the
// customer has completed its generated controls. Shopify/theme code
// may otherwise preselect the first native variant during page load.
if (selector.dataset.compositeOption === 'true'
&& !this.compositeInteractionState[selector.dataset.index]) {
selectedOptions.push(null);
return;
}
if (selector.dataset.selectorType === 'dropdown') {
const selected = selector.querySelector('.custom-select__option[aria-selected="true"]');
selectedOptions.push(selected && selected.dataset.value !== '' ? selected.dataset.value : null);
} else {
const selected = selector.querySelector('input:checked');
selectedOptions.push(selected ? selected.value : null);
}
});
return selectedOptions;
}
/**
* Get the selected option value ids from a list of variant options.
* @returns {Array} Array of selected option value ids, value is null if not selected.
*/
getSelectedOptionIds() {
const selectedOptionIds = [];
this.optionSelectors.forEach((selector) => {
// Ignore Shopify's automatic native selection until the customer has
// actually interacted with a composite picker.
if (selector.dataset.compositeOption === 'true'
&& !this.compositeInteractionState[selector.dataset.index]) {
selectedOptionIds.push(null);
return;
}
if (selector.dataset.selectorType === 'dropdown') {
const selected = selector.querySelector('.custom-select__option[aria-selected="true"]');
selectedOptionIds.push(selected && selected.dataset.value !== '' ? selected.dataset.valueId : null);
} else {
const selected = selector.querySelector('input:checked');
selectedOptionIds.push(selected ? selected.dataset.valueId : null);
}
});
return selectedOptionIds;
}
/**
* Apply search parameters (set by product card swatches).
*/
applySearchParams() {
const searchParams = new URLSearchParams(window.location.search);
Array.from(searchParams.keys()).forEach((key) => {
const value = searchParams.get(key);
const optionSelectors = Array.from(this.optionSelectors);
const matchingOptionSelector = optionSelectors.find((x) => x.dataset.option === key);
if (!matchingOptionSelector) return;
if (matchingOptionSelector.dataset.selectorType === 'dropdown') {
const colorOptionDropdown = matchingOptionSelector.querySelector(`.custom-select__option[data-value="${CSS.escape(value)}"]:not([aria-selected="true"])`);
if (colorOptionDropdown) {
const customSelect = colorOptionDropdown.closest('custom-select');
const convertEvent = (evt) => {
evt.detail.noFocus = true;
customSelect.dispatchEvent(
new CustomEvent('change', { bubbles: true, cancelable: false, detail: evt.detail })
);
};
customSelect.addEventListener('change', convertEvent, { once: true });
customSelect.selectOption(colorOptionDropdown);
}
} else {
const matchingInput = matchingOptionSelector.querySelector(`input[value="${CSS.escape(value)}"]:not(:checked)`);
if (matchingInput) {
const matchingOptionValue = matchingOptionSelector.querySelector('.option-selector__label-value');
if (matchingOptionValue) {
matchingOptionSelector.querySelector('.option-selector__label-value').textContent = value;
}
matchingInput.checked = true;
matchingInput.dispatchEvent(
new CustomEvent('change', { bubbles: true, cancelable: false, detail: { noFocus: true } })
);
}
}
});
}
}
customElements.define('variant-picker', VariantPicker);
}
Snap offers lease-to-own financing.
Apply Now
Xtrusion Overland | SKU:
X01-XTR1-TACO-D-WDW-15H-53L-ST1
Overland Rack Angle Compatibility
One Angle.Every Build.
XTR bed racks use three rack angles — 9°, 13°, and 20° — each matched to specific vehicle setups and accessory families. Find the right angle for your build so every rail, crossbar, and mounting kit lines up perfectly.
Engineered quality
CNC billet strength
We didn't just join the strong extrusions with some sheet metal corner brackets — we put the aluminum where it makes a difference. Every angle variant uses the same 6061-T6 Aerospace grade billet, machined to precision to join all components with exact angular alignment.
✓ All three angles share the same billet standard
✓ 9°, 13°, and 20° brackets machined to exact specs
✓ No sheet metal corners — pure aerospace aluminum
Quick Reference
Angle Compatibility
Rack Angle
Product Line
Compatible Accessories
9°
Gladiator Series
All Gladiator bedracks, Gladiator mounting kits, Gladiator-specific crossbars, including Softopper versions on Gladiator.
13°
Soft Topper Series
Soft Topper bed racks for all truck models (except Gladiator).
20°
Universal / Crossbars
All XTR1, XTR3, XBRS Bed Racks, universal mounting rails, standard rack accessories (except Gladiator or Softopper)
9° angle
Gladiator Series
The 9° angle is dedicated to the Gladiator product line . This angle was engineered specifically for Gladiator rails and their mounting geometry, ensuring a flush, secure connection between your rails and any Xtrusion add-on accessories. This includes Softopper Gladiator Builds
Xo Skeleton Note: The XO.SKLTN Uses a complete different system not to be confused with Gladiator XTR 9 Degree components.
13° angle
Soft Topper Series
The 13° angle serves Soft Topper products . This intermediate angle provides the right compromise between load clearance and low profile when mounting accessories to Soft Topper rails and their compatible hardware.
Crossbars: XTR1 and XTR3 use 13° when mounted on Soft Topper setups.
20° angle
Universal / Crossbars
The 20° angle is the default for all other Xtrusion products — universal mounting rails, standard crossbars, and general overland rack accessories. This is the most common angle in the industry and works with the widest range of roof rack and truck bed rack systems.
Exception: XBRS Crossbars use 9 Degree components, in case you decide to upgrade to full rack, so it remains compatible on joints.
Quick Reference
Xo Skeleton — Angle Breakdown
The Xo Skeleton uses two angles Gladiator Specific or Universal. However, some accessories might be compatible with both, because the top rail consolidates both angles into one. Crossbar mounts or awning mounts are compatible with both versions.
9° Angle
XOSKLTN Gladiator
Gladiator rail mounts
Gladiator-specific hardware
Gladiator crossbar slots
13° Angle
XOSKLTN
Soft Topper rail mounts
Soft Topper mounting kits
Soft Topper crossbar slots
Note: The Xo Skeleton never uses the 20° angle.
How To Choose
The Right Rack Angle, Explained
Every XTR accessory — crossbars, mounting brackets, add-on hardware — is cut for one specific angle. The right angle depends on your primary rail system or vehicle setup. Choosing the matching angle ensures every component in your build aligns correctly and carries load as designed.
Identify your rail system. Gladiator = 9°, Soft Topper = 13°, universal = 20°.
Check your crossbar type. XTR1/XTR3 come in all three — order the one matching your rails.
Stick to one angle per build. Mixing angles creates misalignment and stress points.
What determines the rack angle I need? +
Your rack angle is determined by your primary rail system or vehicle setup. Gladiator rails use 9°, Soft Topper setups use 13°, and universal or standard rack accessories use 20°. Choosing the matching angle ensures every accessory — crossbars, mounting brackets, and add-ons — aligns correctly.
Can I use a 20° crossbar with a Gladiator rail? +
No. Gladiator rails are engineered for 9° accessories. Using a 20° crossbar on a Gladiator rail will result in misalignment and improper load transfer. Always match your crossbar angle to your rail system.
Do Soft Toppers and Gladiator rails use the same angle? +
No. Soft Toppers use a 13° angle while Gladiator rails use a 9° angle. These angles are not interchangeable. If you're building with both types, you'll need separate angle-specific accessory sets.
Are XTR1 and XTR3 crossbars angle-specific? +
XTR1 and XTR3 crossbars come in three angle variants — 9°, 13°, and 20° — to match each rack system. The 20° variant is the default and works with universal racks. The 13° variant is used when paired with Soft Topper setups. Order the angle that matches your primary rail system.
Why do overland racks use different angles? +
Different rack angles exist to accommodate varying vehicle roof geometries, rail profiles, and load requirements. The 9° angle works well with Gladiator-style tubular rails. The 13° angle provides a middle ground for Soft Topper mounting systems. The 20° angle is the industry standard for universal applications and offers maximum load clearance.
The XTR1 Pre-configured Bed Rack for Toyota Tacoma comes ready to roll out with mid-height and cab-height options. It gives you a proven 4-column foundation designed to grow with your truck. Skip the guesswork: choose your rack configuration, then outfit it with our pre-configured accessory packages as your build evolves.
Unlike fixed sheet-metal racks, the XTR1 is built from T-slot aluminum extrusion so your setup can change as your truck build evolves. Mount rooftop tent accessories, recovery boards, awnings, water storage, fuel cans, bikes, kayaks, ladders, conduit, lumber, and jobsite gear without locking your truck into one permanent layout.
What it does
Bolts directly to your Toyota Tacoma’s factory bed rails using the included bedrail brackets. It turns your bed into a fully modular cargo platform: carry a rooftop tent, awning, recovery gear, fuel, and water on top, while keeping the bed underneath free for daily use. Works as a Toyota Tacoma ladder rack , a cargo rack for lumber and conduit, or a no-nonsense overland basecamp — built from solid T-slot aluminum, not flimsy sheet metal.
Built for the Toyota Tacoma
The XTR1 Pre-configured is engineered to bolt directly to the Toyota Tacoma’s factory bed rails. It comes in mid-height and cab-height options so you get the exact fit for your needs. The uprights are pre-cut and pre-drilled for a clean, flush mount. It’s not a universal bracket forced to fit — it’s purpose-built for the geometry and rail spacing of the Tacoma.
Why XTR1 is different
The XTR1 is our original four-upright bed rack and the foundation that established the Xtrusion Overland rack system. Its open design provides excellent side access, outstanding accessory compatibility, and a clean layout that’s equally at home on work trucks, overland builds, and daily drivers. It comes with 4 uprights (one in each corner), 2 side braces , 2 top crossbars , and 2 top support braces . Fewer moving parts, lighter weight, and a clean, traditional look that ages well under trail dust and jobsite grime.
Solid extruded aluminum — the strongest, lightest, most modular material on the market, with no rust and no sacrifice in features.
T-slot on every linear surface — near-infinite mounting points; mount anything in any position and reconfigure in minutes.
4 uprights (one in each corner) — classic, proven load distribution for tents, awnings, recovery gear, and commercial gear.
Pre-configured options — start with a solid rack foundation and add accessories like MOLLE panels, AXS Gates, and slide-out storage exactly when you need them.
Choose XTR1 if you...
Want more than just a rack. It is the starting point for a modular truck system. Start with the bed rack, then add accessories as your needs change. Depending on your setup, the XTR1 can work with MOLLE panels, AXS Gates, slide-out storage, recovery gear mounts, lighting, water storage, and other rack-mounted accessories.
Because the rack is built around T-slot extrusion, you can continue adapting the layout instead of replacing the entire system as your build grows.
What’s included
2 Top Crossbars
4 Uprights (Columns)
2 Top Support Braces (1530 Black)
2 Side Braces (1530 Black)
4 Bedrail Brackets (connect uprights to your Toyota Tacoma bed rails)
4 Top Corner Brackets (connect crossbars and uprights)
All screws and hardware required for assembly
Set of Black XTR Off-Road Reinforcement Plates ($110 value)
Metal Handle Kit — Set of 4 ($40 value)
16 pcs Drop-In T-Nuts — 5/16-8 ($12 value)
16 pcs Button Head Screws — 5/16-8 x 5/8" ($8.80 value)
Pre-configured Accessory Packages
Package 1: Bed Rack only
Package 2: Bed Rack + 8 MOLLE Plates (12x9 plates for mid-height racks & 16x11 plates for cab-height racks)
Package 3A: Bed Rack + 8 MOLLE Plates + 1x AXS Gate Kit + 1x Slide Out
Package 3B: Bed Rack + 8 MOLLE Plates + 2x AXS Gate Kits
Package 3C: Bed Rack + 8 MOLLE Plates + 2x Slide Out Kits
Specifications
Rated capacity (evenly distributed): 450 lb dynamic off-road, 850 lb dynamic on-road, 1,550 lb static — or OEM bed rail capacity, whichever is less.
Looking for something a little different?
We build XTR1 and XTR3 racks for every Toyota Tacoma setup — pick the one for your bed:
Explore more Toyota Tacoma gear
Frequently Asked Questions
What is the difference between the Pre-configured and Custom Height?
The Custom Height is designed to bolt directly to your bed rails as a standalone rack foundation. The XTR1 Pre-configured comes with mid-height and cab-height options and offers ready-to-go accessory packages (MOLLE, AXS Gates, Slide Outs) built specifically for those heights.
Does this require drilling into my Tacoma?
No. The rack mounts directly to the factory bed rails using the included bedrail brackets. No permanent modifications, no drilling, and no compromising your factory finish.
Will this rack work with a Softopper, FasTop, or Bestop on my Tacoma?
No. The XTR1 Pre-configured is designed to bolt to your bed rails, not a topper cover. If you run a canvas-style soft topper, look at our XTR1 Soft Topper Bed Rack or XTR3 Soft Topper Bed Rack instead, both of which are built at a 13.3° angle to match your topper’s sidewalls.
*See Terms and Conditions for additional product disclaimers.