Islands Architecture
Liquid renders complete HTML on the server. Only the interactive parts of the page — the "islands" — receive JavaScript. Each island hydrates independently, on its own schedule, with its own module.
This is partial hydration, adapted for Shopify. Where Astro uses its own compiler and supports multiple frameworks, Kona uses Liquid for server rendering, Web Components for client interactivity, and vite-plugin-shopify-theme-islands by Alex Rees for hydration orchestration. The plugin provides the revive runtime, the client:* directive syntax (which mirrors Astro's API), and the dynamic import machinery that ties it all together.
How revive works
The hydration runtime is imported in the main entry point:
// theme/frontend/entrypoints/theme.js
import 'vite-plugin-shopify-theme-islands/revive'When revive initializes:
- Scans the DOM for custom elements whose tag names match files in
theme/frontend/islands/.<cart-drawer>maps tocart-drawer.js. - Reads directives — each element's
client:*attributes determine when to load (client:idle,client:visible,client:media,client:defer,client:interaction). - Waits for the condition — a
client:visibleisland only loads when it scrolls into view. - Dynamically imports the island module, which calls
customElements.define(). The browser upgrades the element in place and firesconnectedCallback.
Dynamic content and nested islands
Revive uses a MutationObserver to watch for custom elements added after initial page load. This handles section rendering in the theme editor, AJAX content (cart updates, search results), and nested islands.
Islands can contain other islands. When a parent hydrates and renders child custom elements, the observer detects them and queues them for hydration. No manual registration is needed.
If a dynamic import fails, revive retries with exponential backoff for resilience against transient network errors.
Runtime events
Revive dispatches events on document for monitoring:
islands:load — Fired when an island hydrates successfully.
document.addEventListener('islands:load', (event) => {
const { tag, duration, attempt } = event.detail
console.log(`${tag} hydrated in ${duration}ms (attempt ${attempt})`)
})islands:error — Fired when an island fails after all retry attempts.
document.addEventListener('islands:error', (event) => {
console.error(`Failed to load island: ${event.detail.tag}`)
})Progressive enhancement
Because Liquid renders complete HTML, every component works before JavaScript loads. Islands enhance the base experience:
| Component | Without JS | With JS |
|---|---|---|
| Header drawer | <details>/<summary> toggles natively | Smooth animation, focus trap, overlay |
| Variant picker | Radio buttons submit the form | Updates price and URL without reload |
| Cart form | Standard form submits to /cart | AJAX add-to-cart, drawer opens with live update |
| Sticky header | Static header at top | Hides on scroll down, reveals on scroll up |
The HTML is the product. JavaScript adds refinements.
Anatomy of an island
Liquid snippet
<sticky-header client:idle>
<header id="shopify-section-header">
<!-- Full header markup rendered by Liquid -->
</header>
</sticky-header>Island JavaScript
// theme/frontend/islands/sticky-header.js
class StickyHeader extends window.HTMLElement {
connectedCallback() {
this.controller = new AbortController()
this.header = document.getElementById('shopify-section-header')
this.headerBounds = {}
window.addEventListener('scroll', this.onScroll.bind(this), {
signal: this.controller.signal
})
this.createObserver()
}
disconnectedCallback() {
this.controller?.abort()
}
createObserver() {
const observer = new IntersectionObserver((entries, observer) => {
this.headerBounds = entries[0].intersectionRect
observer.disconnect()
})
observer.observe(this.header)
}
onScroll() {
// Show/hide header based on scroll direction
}
}
window.customElements.define('sticky-header', StickyHeader)Key patterns:
connectedCallbacksets up event listeners withAbortControllerfor cleanupdisconnectedCallbackcallsthis.controller.abort()to remove all listenerscustomElements.defineat the bottom registers the element — called once on import, the browser upgrades all matching elements- No constructor DOM access — DOM queries happen in
connectedCallback, not the constructor
Naming convention
The filename maps directly to the custom element tag name:
| File | Tag |
|---|---|
cart-drawer.js | <cart-drawer> |
product-form.js | <product-form> |
sticky-header.js | <sticky-header> |
Custom element names must contain a hyphen — this is a web platform requirement.
Component inheritance
Some islands share behavior through class inheritance. DetailsModal is a base class providing open/close, focus trap, and overlay behavior:
details-modal.js (base class)
├── header-drawer.js
└── password-modal.jsNext steps
- Hydration Directives — Choose when each island loads
- Creating Islands — Build your first island step by step