Assets: CSS, JS, and Front-End Architecture Intermediate 12 min read

JavaScript Patterns: Progressive Enhancement

Learn to write resilient JavaScript that enhances the user experience while ensuring your theme works for everyone, regardless of their browser or connection.

Progressive enhancement is a philosophy: build a functional baseline, then layer on enhancements for capable browsers. Your theme should work without JavaScript, then become better with it.

The Progressive Enhancement Mindset

Think of your theme in layers:

  1. HTML: Semantic, accessible content
  2. CSS: Visual design and basic interactions
  3. JavaScript: Enhanced interactions and dynamic features

If JavaScript fails (slow connection, error, disabled), layers 1 and 2 still work.

Feature Detection

Check if a feature exists before using it:

// Check for specific API
if ('IntersectionObserver' in window) {
// Use Intersection Observer for lazy loading
} else {
// Fall back to loading all images
}
// Check for method
if (document.body.animate) {
// Use Web Animations API
}
// Check for CSS support
if (CSS.supports('display', 'grid')) {
// Grid is supported
}

Avoid User Agent Sniffing

Don’t detect specific browsers:

// BAD: Fragile and unreliable
if (navigator.userAgent.includes('Chrome')) {
}
// GOOD: Test for the feature you need
if ('loading' in HTMLImageElement.prototype) {
}

No-JS Fallbacks

The no-js Class Pattern

Add a no-js class to <html> and replace it with JavaScript:

<html class="no-js">
<head>
<script>
document.documentElement.classList.replace('no-js', 'js');
</script>
</head>
</html>

Style fallbacks for no-JS:

/* Default: Show mobile menu toggle */
.menu-toggle {
display: block;
}
/* With JS: Hide toggle, show drawer functionality */
.js .menu-toggle {
display: block;
}
/* Without JS: Show full menu always */
.no-js .nav-menu {
display: block;
position: static;
}
.no-js .menu-toggle {
display: none;
}

Noscript Element

Provide alternative content:

<noscript>
<style>
.js-only {
display: none;
}
.fallback-content {
display: block;
}
</style>
</noscript>

Passing Liquid Data to JavaScript

Data Attributes

<div
class="product-form"
data-product-id="{{ product.id }}"
data-available="{{ product.available }}"
data-variants="{{ product.variants | json | escape }}"
>
const form = document.querySelector('.product-form');
const productId = form.dataset.productId;
const isAvailable = form.dataset.available === 'true';
const variants = JSON.parse(form.dataset.variants);

Script Tags with JSON

For larger data structures:

<script type="application/json" id="product-data">
{
"id": {{ product.id }},
"title": {{ product.title | json }},
"available": {{ product.available }},
"variants": {{ product.variants | json }},
"options": {{ product.options_with_values | json }}
}
</script>
const productData = JSON.parse(document.getElementById('product-data').textContent);

Global Configuration Object

<script>
window.themeConfig = {
routes: {
cart: '{{ routes.cart_url }}',
cartAdd: '{{ routes.cart_add_url }}',
cartChange: '{{ routes.cart_change_url }}'
},
strings: {
addToCart: {{ 'products.product.add_to_cart' | t | json }},
soldOut: {{ 'products.product.sold_out' | t | json }},
unavailable: {{ 'products.product.unavailable' | t | json }}
},
money_format: {{ shop.money_format | json }}
};
</script>

Loading Strategies

Defer Loading

Scripts with defer execute after HTML parsing, in order:

<script src="{{ 'vendor.js' | asset_url }}" defer></script>
<script src="{{ 'theme.js' | asset_url }}" defer></script>

Async Loading

Scripts with async execute as soon as they download:

<script src="{{ 'analytics.js' | asset_url }}" async></script>

Use for independent scripts that don’t rely on order.

Dynamic Import

Load scripts only when needed:

// Load when user interacts with video
document.querySelector('.video-player').addEventListener('click', async () => {
const { initVideoPlayer } = await import('./video-player.js');
initVideoPlayer();
});

Module Scripts

Modern browsers support ES modules:

<script type="module">
import { initCart } from '{{ "cart.js" | asset_url }}';
initCart();
</script>

With legacy fallback:

<script type="module" src="{{ 'theme.js' | asset_url }}"></script>
<script nomodule src="{{ 'theme-legacy.js' | asset_url }}" defer></script>

Event Delegation

Instead of attaching listeners to many elements, use one listener on a parent:

// BAD: Listener on every button
document.querySelectorAll('.add-to-cart').forEach((button) => {
button.addEventListener('click', handleAddToCart);
});
// GOOD: One listener on container
document.addEventListener('click', (event) => {
const button = event.target.closest('.add-to-cart');
if (button) {
handleAddToCart(event, button);
}
});

Benefits:

  • Works with dynamically added elements
  • Less memory usage
  • Simpler code

Custom Elements

Create reusable, encapsulated components:

class QuantitySelector extends HTMLElement {
constructor() {
super();
this.input = this.querySelector('input');
this.minusButton = this.querySelector('[data-action="minus"]');
this.plusButton = this.querySelector('[data-action="plus"]');
}
connectedCallback() {
this.minusButton.addEventListener('click', () => this.decrease());
this.plusButton.addEventListener('click', () => this.increase());
}
decrease() {
const min = parseInt(this.input.min) || 1;
const current = parseInt(this.input.value);
if (current > min) {
this.input.value = current - 1;
this.dispatchChange();
}
}
increase() {
const max = parseInt(this.input.max) || Infinity;
const current = parseInt(this.input.value);
if (current < max) {
this.input.value = current + 1;
this.dispatchChange();
}
}
dispatchChange() {
this.dispatchEvent(
new CustomEvent('quantity-change', {
detail: { quantity: parseInt(this.input.value) },
bubbles: true,
})
);
}
}
customElements.define('quantity-selector', QuantitySelector);
<quantity-selector>
<button type="button" data-action="minus">-</button>
<input type="number" value="1" min="1">
<button type="button" data-action="plus">+</button>
</quantity-selector>

Without JavaScript, the input still works.

Accessible Interactivity

Keyboard Support

element.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleActivation();
}
if (event.key === 'Escape') {
closeModal();
}
});

Focus Management

function openModal(modal) {
modal.showModal();
// Save what had focus
modal.previousFocus = document.activeElement;
// Focus first focusable element
modal.querySelector('button, [href], input').focus();
}
function closeModal(modal) {
modal.close();
// Restore focus
modal.previousFocus?.focus();
}

ARIA Updates

function toggleAccordion(button) {
const expanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', !expanded);
const panel = document.getElementById(button.getAttribute('aria-controls'));
panel.hidden = expanded;
}

Error Handling

Wrap critical functionality:

async function addToCart(variantId, quantity) {
try {
const response = await fetch('/cart/add.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: variantId, quantity }),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const cart = await response.json();
return cart;
} catch (error) {
console.error('Add to cart failed:', error);
// Show user-friendly message
showNotification('Could not add to cart. Please try again.');
throw error;
}
}

Complete Component Example

A product variant selector with progressive enhancement:

{# Works without JS: Standard select #}
<variant-selector class="variant-selector" data-url="{{ product.url }}">
<select name="id" class="variant-selector__select">
{%- for variant in product.variants -%}
<option
value="{{ variant.id }}"
{% if variant == product.selected_or_first_available_variant %}selected{% endif %}
{% unless variant.available %}disabled{% endunless %}
>
{{ variant.title }} - {{ variant.price | money }}
{%- unless variant.available %} (Sold out){% endunless %}
</option>
{%- endfor -%}
</select>
{# Enhanced UI: Custom buttons (hidden by default, shown by JS) #}
<div class="variant-selector__buttons" hidden>
{%- for variant in product.variants -%}
<button
type="button"
class="variant-selector__button"
data-variant-id="{{ variant.id }}"
data-available="{{ variant.available }}"
{% unless variant.available %}disabled{% endunless %}
>
{{ variant.title }}
</button>
{%- endfor -%}
</div>
</variant-selector>
<script type="application/json" id="variant-data-{{ product.id }}">
{{ product.variants | json }}
</script>
class VariantSelector extends HTMLElement {
constructor() {
super();
this.select = this.querySelector('select');
this.buttons = this.querySelector('.variant-selector__buttons');
this.productUrl = this.dataset.url;
}
connectedCallback() {
// Show enhanced UI, hide select
if (this.buttons) {
this.buttons.hidden = false;
this.select.hidden = true;
this.buttons.addEventListener('click', (e) => {
const button = e.target.closest('.variant-selector__button');
if (button && !button.disabled) {
this.selectVariant(button.dataset.variantId);
}
});
}
}
selectVariant(variantId) {
// Update select for form submission
this.select.value = variantId;
// Update button states
this.buttons.querySelectorAll('.variant-selector__button').forEach((btn) => {
btn.classList.toggle('is-selected', btn.dataset.variantId === variantId);
});
// Update URL
const url = new URL(this.productUrl, window.location.origin);
url.searchParams.set('variant', variantId);
history.replaceState({}, '', url);
// Dispatch event for other components
this.dispatchEvent(
new CustomEvent('variant-change', {
detail: { variantId },
bubbles: true,
})
);
}
}
customElements.define('variant-selector', VariantSelector);

Practice Exercise

Convert this JavaScript to use progressive enhancement:

// BEFORE: Only works with JS
document.querySelectorAll('.accordion-trigger').forEach((trigger) => {
trigger.addEventListener('click', () => {
const panel = trigger.nextElementSibling;
panel.classList.toggle('is-open');
});
});

After: Progressive enhancement:

<!-- HTML: Works without JS using details/summary -->
<details class="accordion">
<summary class="accordion__trigger">Question 1</summary>
<div class="accordion__content">
<p>Answer content here...</p>
</div>
</details>
/* CSS: Style the native behavior */
.accordion__trigger {
cursor: pointer;
padding: var(--spacing-md);
font-weight: 600;
}
.accordion__content {
padding: 0 var(--spacing-md) var(--spacing-md);
}
// JS: Enhance with animations (optional)
document.querySelectorAll('.accordion').forEach((accordion) => {
const content = accordion.querySelector('.accordion__content');
accordion.addEventListener('toggle', () => {
if (accordion.open) {
content.animate(
[
{ opacity: 0, transform: 'translateY(-10px)' },
{ opacity: 1, transform: 'translateY(0)' },
],
{ duration: 200, easing: 'ease-out' }
);
}
});
});

Key Takeaways

  1. Build for the baseline first: HTML forms, links, and native elements work everywhere
  2. Detect features, not browsers: Test for what you need
  3. Use the no-js class pattern for CSS fallbacks
  4. Pass Liquid data via JSON in data attributes or script tags
  5. Prefer defer for scripts that depend on DOM
  6. Use event delegation for dynamic content
  7. Custom elements encapsulate enhanced behavior
  8. Handle errors gracefully with user-friendly messages
  9. Maintain accessibility with keyboard support and ARIA

What’s Next?

Now that you understand progressive enhancement patterns, the next lesson covers Working with Shopify’s Built-in JS Utilities and the APIs available in every Shopify theme.

Finished this lesson?

Mark it complete to track your progress.

Discussion

Loading comments...