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:
- HTML: Semantic, accessible content
- CSS: Visual design and basic interactions
- 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 APIif ('IntersectionObserver' in window) { // Use Intersection Observer for lazy loading} else { // Fall back to loading all images}
// Check for methodif (document.body.animate) { // Use Web Animations API}
// Check for CSS supportif (CSS.supports('display', 'grid')) { // Grid is supported}Avoid User Agent Sniffing
Don’t detect specific browsers:
// BAD: Fragile and unreliableif (navigator.userAgent.includes('Chrome')) {}
// GOOD: Test for the feature you needif ('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 videodocument.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 buttondocument.querySelectorAll('.add-to-cart').forEach((button) => { button.addEventListener('click', handleAddToCart);});
// GOOD: One listener on containerdocument.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 JSdocument.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
- Build for the baseline first: HTML forms, links, and native elements work everywhere
- Detect features, not browsers: Test for what you need
- Use the
no-jsclass pattern for CSS fallbacks - Pass Liquid data via JSON in data attributes or script tags
- Prefer
deferfor scripts that depend on DOM - Use event delegation for dynamic content
- Custom elements encapsulate enhanced behavior
- Handle errors gracefully with user-friendly messages
- 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...