npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

vite-plugin-shopify-theme-islands

v1.2.2

Published

Vite plugin for island architecture in Shopify themes

Readme

vite-plugin-shopify-theme-islands

npm version npm downloads license

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-islands

Setup

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.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.

// 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: visiblemediaidledeferinteraction → 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 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