Creating Islands
This guide walks through creating a new island component from scratch -- from the JavaScript file to the Liquid snippet to seeing it hydrate in the browser.
What Is an Island?
An island is a Web Component (custom element) that hydrates a server-rendered Liquid snippet with client-side interactivity. The Liquid template renders the HTML; the island file adds behavior.
Each island file in theme/frontend/islands/ maps to a custom element tag name. The filename determines the tag: collapsible-text.js becomes <collapsible-text>.
Step 1: Create the Island File
Create a new file at theme/frontend/islands/collapsible-text.js:
class CollapsibleText extends window.HTMLElement {
connectedCallback() {
this.controller = new AbortController()
const { signal } = this.controller
this.button = this.querySelector('[data-toggle]')
this.content = this.querySelector('[data-content]')
this.button.addEventListener('click', this.toggle.bind(this), { signal })
}
disconnectedCallback() {
this.controller?.abort()
}
toggle() {
const expanded = this.button.getAttribute('aria-expanded') === 'true'
this.button.setAttribute('aria-expanded', String(!expanded))
this.content.toggleAttribute('hidden', expanded)
}
}
window.customElements.define('collapsible-text', CollapsibleText)Key points:
- Extend
window.HTMLElement(always use the window reference). - Set up
AbortControllerinconnectedCallback, not the constructor. - Pass
{ signal }to everyaddEventListenercall. - Call
this.controller?.abort()indisconnectedCallback. - Register with
customElements.define()at the bottom of the file.
Step 2: Create the Liquid Snippet
Create theme/snippets/collapsible-text.liquid:
{% doc %}
Renders a collapsible text block with a toggle button.
@param {string} heading - The toggle button text
@param {string} content - The collapsible body text
{% enddoc %}
<collapsible-text client:visible>
<button
data-toggle
type="button"
aria-expanded="false"
aria-controls="CollapsibleContent-{{ section.id }}"
class="flex w-full items-center justify-between py-3 text-left"
>
<span>{{ heading }}</span>
<span aria-hidden="true">+</span>
</button>
<div
data-content
id="CollapsibleContent-{{ section.id }}"
hidden
class="pb-4"
>
{{ content }}
</div>
</collapsible-text>The HTML is fully functional server-side -- the hidden attribute hides the content, and the button does nothing until JavaScript hydrates. This is progressive enhancement.
Step 3: Use in a Section or Block
Render the snippet from any section or block:
{% render 'collapsible-text',
heading: block.settings.heading,
content: block.settings.content
%}How Revive Discovers Islands
The hydration runtime (vite-plugin-shopify-theme-islands/revive) uses import.meta.glob() to build a map of all theme/frontend/islands/*.js files at build time. When the page loads, it:
- Scans the DOM for custom elements with kebab-case tag names.
- Checks if the tag name matches an island filename.
- Reads the hydration directive attribute (
client:idle,client:visible, orclient:media). - Dynamically imports the matching island file using the appropriate strategy.
You do not need to manually register islands in any central file. Placing a .js file in the islands/ directory and using the matching custom element tag in Liquid is all that is required.
Hydration Directives
Set a hydration directive as an HTML attribute on the custom element tag to control when the island loads:
client:idle
Loads when the browser's main thread is free, using requestIdleCallback. Best for components needed soon after page load but not immediately visible (e.g., cart drawer, product form).
<cart-drawer client:idle>
...
</cart-drawer>client:visible
Loads when the element enters the viewport, using IntersectionObserver. Best for components below the fold (e.g., product recommendations, footer localization).
<product-recommendations client:visible>
...
</product-recommendations>client:media
Loads when a CSS media query matches. Best for components that only exist at certain breakpoints (e.g., mobile menu drawer).
<header-drawer client:media="(max-width: 1023px)">
...
</header-drawer>Extending an Existing Island
If your new island shares behavior with an existing one, extend its class instead of duplicating code:
import DetailsModal from '@/islands/details-modal'
class MyModal extends DetailsModal {
open(event) {
// Custom open behavior
super.open(event)
this.classList.add('my-modal--active')
}
}
window.customElements.define('my-modal', MyModal)The parent class must use export default for this pattern. See the existing hierarchy:
DetailsModalis extended byHeaderDrawerandPasswordModalCartItemsis extended byCartDrawerItemsVariantSelectsis extended byVariantRadios
Checklist
When creating a new island, verify:
- File location --
theme/frontend/islands/<tag-name>.js - Filename matches tag --
collapsible-text.jsdefines<collapsible-text> - AbortController -- created in
connectedCallback, aborted indisconnectedCallback - All listeners use signal --
addEventListener(type, handler, { signal }) - All fetch calls use signal --
fetch(url, { signal: this.controller.signal }) - Minimal constructor -- only
super()and static reads; no listeners, no DOM queries that depend on parent context - Hydration directive -- the Liquid template sets
client:idle,client:visible, orclient:media - Progressive enhancement -- the server-rendered HTML works without JavaScript
- Import alias -- use
@/for all project imports (e.g.,@/lib/events) - No semicolons -- follow the project style convention
Further Reading
- Lifecycle -- detailed callback patterns and anti-patterns
- Event System -- dispatching and listening to theme events
- Component Reference -- all 16 production islands
- Utilities -- helper functions available to islands