# vite-plugin-shopify-theme-islands

[![npm version](https://img.shields.io/npm/v/vite-plugin-shopify-theme-islands)](https://www.npmjs.com/package/vite-plugin-shopify-theme-islands)
[![npm downloads](https://img.shields.io/npm/dm/vite-plugin-shopify-theme-islands)](https://www.npmjs.com/package/vite-plugin-shopify-theme-islands)
[![license](https://img.shields.io/npm/l/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
