vite-plugin-shopify-theme-islands
v1.2.2
Published
Vite plugin for island architecture in Shopify themes
Maintainers
Readme
vite-plugin-shopify-theme-islands
Island architecture for Shopify themes. Lazily hydrate custom elements using loading directives — only load the JavaScript when it's actually needed.
Installation
bun add -d vite-plugin-shopify-theme-islands
npm install -D vite-plugin-shopify-theme-islands
pnpm add -D vite-plugin-shopify-theme-islands
yarn add -D vite-plugin-shopify-theme-islandsSetup
1. Add the plugin to vite.config.ts
import { defineConfig } from "vite";
import shopifyThemeIslands from "vite-plugin-shopify-theme-islands";
export default defineConfig({
plugins: [shopifyThemeIslands()],
});2. Import the runtime in your entrypoint
import "vite-plugin-shopify-theme-islands/revive";That's it. The plugin automatically scans your islands directory and wires everything up.
For SPA navigation teardown, import disconnect to stop the MutationObserver:
import { disconnect } from "vite-plugin-shopify-theme-islands/revive";
// Call during SPA teardown:
disconnect();Writing islands
Two approaches — use either or both.
Directory scanning
Drop files into your islands directory and they're automatically picked up. The filename (without extension) must match the custom element tag name used in your Liquid templates.
frontend/js/islands/
product-form.ts → <product-form>
cart-drawer.ts → <cart-drawer>
forms/checkout-form.ts → <checkout-form>Filenames must contain a hyphen (
product-form.tsnotproductform.ts) — this is a Web Components requirement. Filenames must also be lowercase to match the tag name.
Subdirectories are supported. Any file in the directory becomes an island automatically.
// frontend/js/islands/product-form.ts
class ProductForm extends HTMLElement {
connectedCallback() {
// ...
}
}
if (!customElements.get("product-form")) {
customElements.define("product-form", ProductForm);
}Island mixin
Mark any file as an island with the Island mixin, regardless of where it lives. Import it and extend from Island(HTMLElement) instead of HTMLElement — everything else stays identical.
// frontend/js/components/site-footer.ts
import Island from "vite-plugin-shopify-theme-islands/island";
class SiteFooter extends Island(HTMLElement) {
connectedCallback() {
// ...
}
}
if (!customElements.get("site-footer")) {
customElements.define("site-footer", SiteFooter);
}The plugin detects the mixin import at build time and includes the file as a lazy island chunk — no directory config needed.
Which to use
| | Directory scanning | Island mixin |
| ----------------- | ---------------------------------- | ------------------------------ |
| File organisation | Dedicated islands directory | Co-located anywhere |
| Opt-in style | Convention (everything in the dir) | Explicit (per file) |
| Auditability | One directory to check | Search for /island import |
| Build overhead | None | Filesystem scan at build start |
Both can be used together — directory scanning for new islands, the mixin for existing components you want to adopt without moving.
Child island cascade
Child islands nested inside a parent island are automatically held until the parent's module has loaded. The runtime re-walks the parent's subtree on success, so child islands activate with their normal directives intact — no extra configuration needed.
<product-form client:visible>
<!-- tab-switcher will not load until product-form has loaded -->
<tab-switcher client:idle></tab-switcher>
</product-form>Directives
Add these attributes to your custom elements in Liquid to control when the JavaScript loads. Without a directive, the island loads immediately.
client:visible
Loads the island when the element scrolls into view.
<product-recommendations client:visible>
<!-- ... -->
</product-recommendations>The attribute value overrides the global rootMargin for that element only:
<!-- load only once fully visible (no pre-load margin) -->
<product-recommendations client:visible="0px">
<!-- ... -->
</product-recommendations>client:media
Loads the island when a CSS media query matches.
<mobile-menu client:media="(max-width: 768px)">
<!-- ... -->
</mobile-menu>An empty attribute (client:media="") logs a console warning and skips the media check — the island still loads.
client:idle
Loads the island once the browser is idle (uses requestIdleCallback with a 500ms deadline, falls back to setTimeout).
<recently-viewed client:idle>
<!-- ... -->
</recently-viewed>The attribute value overrides the global timeout for that element only:
<!-- wait up to 2 seconds for idle time before loading -->
<recently-viewed client:idle="2000">
<!-- ... -->
</recently-viewed>client:defer
Loads the island after a fixed delay. The delay in milliseconds is read from the attribute value. If no value is given, the configured default (3000ms) is used.
<chat-widget client:defer="3000">
<!-- ... -->
</chat-widget>
<!-- uses the default 3000ms delay -->
<analytics-widget client:defer>
<!-- ... -->
</analytics-widget>Unlike client:idle, which waits for genuine browser idle time, client:defer always waits exactly the specified number of milliseconds.
client:interaction
Loads the island when the user interacts with the element. Listens for mouseenter, touchstart, and focusin by default — the module starts downloading the moment the user moves their cursor toward or focuses the element.
<cart-flyout client:interaction>
<!-- ... -->
</cart-flyout>The attribute value overrides the events for that element only (space-separated MDN event names):
<!-- only mouseenter — touchstart and focusin are excluded -->
<cart-flyout client:interaction="mouseenter">
<!-- ... -->
</cart-flyout>Combine with client:visible to avoid attaching listeners to off-screen elements. Because directives resolve sequentially, interaction listeners are only registered once the element has entered the viewport:
<mega-menu client:visible client:interaction>
<!-- loads when visible, then waits for hover/touch/focus -->
</mega-menu>Combining directives
Directives can be combined — the element works through each condition in sequence before loading. The resolution order is: visible → media → idle → defer → interaction → custom directives.
<!-- must scroll into view, then wait for user interaction -->
<product-recommendations client:visible client:interaction>
<!-- ... -->
</product-recommendations>
<!-- must scroll into view, then wait for idle time -->
<heavy-widget client:visible client:idle>
<!-- ... -->
</heavy-widget>Because conditions resolve sequentially, each directive is only evaluated after the previous one has passed. Interaction listeners, for example, are never attached to an element that isn't yet visible.
Custom directives
Register your own loading conditions via directives.custom. A custom directive is a function that receives a load callback and decides when to call it.
1. Write the directive
// src/directives/hash.ts
import type { ClientDirective } from "vite-plugin-shopify-theme-islands";
const hashDirective: ClientDirective = (load, opts) => {
const target = opts.value;
if (location.hash === target) { load(); return; }
window.addEventListener("hashchange", () => {
if (location.hash === target) load();
});
};
export default hashDirective;Useful for anchor-linked sections — <product-reviews client:hash="#reviews"> loads only when the URL fragment matches, so deep-links like /products/shirt#reviews activate the island immediately while other visitors never load it.
The function signature is (load, options, el) => void | Promise<void>:
| Parameter | Type | Description |
| --------------- | ---------------------- | ----------------------------------------------------- |
| load | () => Promise<void> | Call this to trigger the island module load |
| options.name | string | The matched attribute name, e.g. 'client:hash' |
| options.value | string | The attribute value; empty string if no value was set |
| el | HTMLElement | The island element |
2. Register it in the plugin config
// vite.config.ts
import shopifyThemeIslands from "vite-plugin-shopify-theme-islands";
export default defineConfig({
plugins: [
shopifyThemeIslands({
directives: {
custom: [{ name: "client:hash", entrypoint: "./src/directives/hash.ts" }],
},
}),
],
});The entrypoint supports Vite aliases.
3. Use it in Liquid
<product-reviews client:hash="#reviews">
<!-- ... -->
</product-reviews>Ordering
Built-in directives always run first. A custom directive is only invoked after all built-in conditions on the element have been met. This means you can gate a custom directive behind client:visible to avoid wiring event listeners for off-screen elements:
<!-- element must enter the viewport before the hash handler is registered -->
<product-reviews client:visible client:hash="#reviews">
<!-- ... -->
</product-reviews>The custom directive owns the load() call — the built-in chain never calls it directly when a custom directive is matched.
If a custom directive throws or returns a rejected promise, the runtime dispatches islands:error and abandons that island activation attempt.
Multiple custom directives on the same element use AND semantics — the island loads only once all matched directives have called load(). For example, given two registered custom directives client:hash and client:network:
<!-- client:visible runs first (built-in); then both client:hash and client:network must fire -->
<product-reviews client:visible client:hash="#reviews" client:network="4g">
<!-- ... -->
</product-reviews>Timeout guard
By default, a custom directive that never calls load() silently keeps the island unloaded forever. Set directiveTimeout to fire islands:error and abandon the island if the directive hasn't resolved within the given window:
shopifyThemeIslands({
directiveTimeout: 5000, // abandon after 5 seconds
});This is useful during development to surface directives that hang due to bugs, or in production to ensure broken directives don't silently degrade the experience.
Configuration
| Option | Type | Default | Description |
| ------------------ | -------------------- | --------------------------- | ---------------------------------------------------------------------------------- |
| directories | string \| string[] | ['/frontend/js/islands/'] | Directories to scan for island files. Accepts Vite aliases. |
| directives | object | see below | Per-directive configuration — attribute names, timing options, and custom entries. |
| retry | object | — | Automatic retry behaviour for failed island loads. See Retries. |
| debug | boolean | false | Log discovered islands at build time and directive events in the browser console. |
| directiveTimeout | number | 0 (disabled) | Milliseconds before a custom directive that never calls load() is considered timed out. Fires islands:error and abandons the island. |
Directive defaults
shopifyThemeIslands({
directives: {
visible: {
attribute: "client:visible", // HTML attribute name
rootMargin: "200px", // passed to IntersectionObserver — pre-loads before scrolling into view
threshold: 0, // passed to IntersectionObserver — ratio of element that must be visible
},
idle: {
attribute: "client:idle", // HTML attribute name
timeout: 500, // deadline (ms) for requestIdleCallback; also the setTimeout fallback delay
},
media: {
attribute: "client:media", // HTML attribute name
},
defer: {
attribute: "client:defer", // HTML attribute name
delay: 3000, // fallback delay (ms) when the attribute has no value
},
interaction: {
attribute: "client:interaction", // HTML attribute name
events: ["mouseenter", "touchstart", "focusin"], // DOM events that trigger load
},
custom: [], // custom directives — see Custom directives above
},
});All options are optional — only override what you need. Partial overrides preserve the other defaults:
// Only change rootMargin — attribute and threshold keep their defaults
shopifyThemeIslands({
directives: {
visible: { rootMargin: "400px" },
},
});Multiple island directories
shopifyThemeIslands({
directories: ["/frontend/js/islands/", "/frontend/js/components/"],
});Using Vite aliases
export default defineConfig({
resolve: {
alias: { "@islands": "/frontend/js/islands" },
},
plugins: [
shopifyThemeIslands({
directories: ["@islands/"],
}),
],
});Retries
Automatically retry failed island loads with exponential backoff:
shopifyThemeIslands({
retry: {
retries: 2, // number of retries after the initial failure. Default: 0 (no retry)
delay: 1000, // base delay in ms; doubles each attempt (1s, 2s, 4s…). Default: 1000
},
});Once retries are exhausted the island is dequeued — a fresh activation requires a new element instance.
Lifecycle events
The runtime dispatches DOM events on document for observability use cases such as analytics and error reporting.
Typed helpers
The /events entry point provides typed helpers that unwrap e.detail for you and return a cleanup function:
import { onIslandLoad, onIslandError } from "vite-plugin-shopify-theme-islands/events";
const offLoad = onIslandLoad(({ tag, duration, attempt }) => {
analytics.track("island_loaded", { tag, duration, attempt });
});
const offError = onIslandError(({ tag, error, attempt }) => {
errorReporter.capture(error, { context: tag, attempt });
});
// Remove listeners when no longer needed (e.g. SPA teardown)
offLoad();
offError();Raw DOM events
The events are also available via the standard document.addEventListener API. Event types are fully typed via DocumentEventMap augmentation — available automatically when vite-plugin-shopify-theme-islands is present in your TypeScript compilation (e.g. via vite.config.ts or a directive type import).
document.addEventListener("islands:load", (e) => {
analytics.track("island_loaded", { tag: e.detail.tag });
});| Event | Detail properties | When it fires |
| --------------- | ------------------------------ | ---------------------------------------------------------- |
| islands:load | tag, duration, attempt | Island module resolves successfully |
| islands:error | tag, error, attempt | Load fails, custom directive throws or rejects, or directiveTimeout expires (alongside console.error) |
islands:error fires on each retry attempt, not just the final failure. Multiple independent listeners are supported — each receives its own event.
AI Agents
If you use an AI coding agent (Claude Code, Cursor, Copilot, etc.), run once after installing:
npx @tanstack/intent@latest installThis maps the bundled skills to your agent config so your agent gets accurate v1 API guidance. Skills update automatically with npm updates — no re-run needed.
License
MIT
