vite-plugin-shopify-theme-islands
Version:
Vite plugin for island architecture in Shopify themes
485 lines (354 loc) • 17.8 kB
Markdown
# vite-plugin-shopify-theme-islands
[](https://www.npmjs.com/package/vite-plugin-shopify-theme-islands)
[](https://www.npmjs.com/package/vite-plugin-shopify-theme-islands)
[](./LICENSE)
Island architecture for Shopify themes. Lazily hydrate custom elements using loading directives — only load the JavaScript when it's actually needed.
## Installation
```bash
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-islands
```
## Setup
### 1. Add the plugin to `vite.config.ts`
```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
```ts
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:
```ts
import { disconnect } from "vite-plugin-shopify-theme-islands/revive";
// Call during SPA teardown:
disconnect();
```
If `disconnect()` is called before `DOMContentLoaded`, the runtime also cancels its pending startup listener so islands never initialize later against stale DOM.
## 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.ts` not `productform.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.
```ts
// 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.
```ts
// 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.
```html
<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.
```html
<product-recommendations client:visible>
<!-- ... -->
</product-recommendations>
```
The attribute value overrides the global `rootMargin` for that element only:
```html
<!-- 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.
```html
<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`).
```html
<recently-viewed client:idle>
<!-- ... -->
</recently-viewed>
```
The attribute value overrides the global `timeout` for that element only:
```html
<!-- 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.
```html
<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.
```html
<cart-flyout client:interaction>
<!-- ... -->
</cart-flyout>
```
The attribute value overrides the events for that element only:
```html
<!-- only mouseenter — touchstart and focusin are excluded -->
<cart-flyout client:interaction="mouseenter">
<!-- ... -->
</cart-flyout>
```
In plugin config, `directives.interaction.events` is intentionally narrower than the raw HTML attribute surface. The typed config only accepts the curated package-owned set `mouseenter`, `touchstart`, and `focusin`, and rejects empty arrays.
Per-element `client:interaction="..."` values are also validated at runtime against that same curated set. Unsupported tokens log a warning and are ignored. If no supported tokens remain, the runtime logs a warning and falls back to the configured default interaction events instead of attaching unsupported listeners.
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:
```html
<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.
```html
<!-- 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
```ts
// 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
```ts
// 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
```html
<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:
```html
<!-- 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`:
```html
<!-- 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:
```ts
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](#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
```ts
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"], // curated config 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:
```ts
// Only change rootMargin — attribute and threshold keep their defaults
shopifyThemeIslands({
directives: {
visible: { rootMargin: "400px" },
},
});
```
For `directives.interaction.events`, supported config values are currently limited to `mouseenter`, `touchstart`, and `focusin`. Passing `[]` or unsupported names causes config resolution to fail.
### Multiple island directories
```ts
shopifyThemeIslands({
directories: ["/frontend/js/islands/", "/frontend/js/components/"],
});
```
### Using Vite aliases
```ts
export default defineConfig({
resolve: {
alias: { "@islands": "/frontend/js/islands" },
},
plugins: [
shopifyThemeIslands({
directories: ["@islands/"],
}),
],
});
```
## Retries
Automatically retry failed island loads with exponential backoff:
```ts
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:
```ts
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();
```
For SPA teardown, the virtual `/revive` module also exports `disconnect()`, which stops further lifecycle observation and cancels pending startup before init has run.
### 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).
```ts
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:
```bash
npx @tanstack/intent@latest install
```
This 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