vite-plugin-shopify-theme-islands

Vite plugin for island architecture in Shopify themes

directives

core
231 linesSource

Built-in client directives: client:visible (IntersectionObserver, rootMargin), client:media (matchMedia query), client:idle (requestIdleCallback), client:defer (setTimeout delay), client:interaction (mouseenter/touchstart/focusin). Directives resolve sequentially — visible → media → idle → defer → interaction → custom. Per-element value overrides. Empty client:media warning.

Setup

Add one or more directives as HTML attributes on any custom element:

html
<!-- Load when element scrolls into view (200px pre-load margin by default) -->
<product-form client:visible></product-form>

<!-- Load when CSS media query matches -->
<mobile-nav client:media="(max-width: 768px)"></mobile-nav>

<!-- Load during browser idle time -->
<site-footer client:idle></site-footer>

<!-- Load after a fixed delay (ms) -->
<chat-widget client:defer="5000"></chat-widget>

<!-- Load on mouseenter, touchstart, or focusin (hover/touch/keyboard intent) -->
<cart-flyout client:interaction></cart-flyout>

No JS changes needed — the runtime reads these attributes during DOM walk.

Core Patterns

Combining directives — sequential resolution order

Directives resolve in a fixed order: visible → media → idle → defer → interaction → custom. Each condition is only evaluated after the previous one has passed.

html
<!-- Loads when visible AND on interaction — interaction listeners only attach after scroll-in -->
<mega-menu client:visible client:interaction></mega-menu>

<!-- Loads when visible AND the media query matches -->
<product-recommendations
  client:visible
  client:media="(min-width: 768px)"
></product-recommendations>

Combined directives are AND-latched. The island loads only after every condition resolves. There is no OR mode.

Per-element value overrides

html
<!-- Override global rootMargin for this element only -->
<hero-banner client:visible="0px"></hero-banner>

<!-- Override global idle timeout for this element (ms) -->
<analytics-widget client:idle="2000"></analytics-widget>

<!-- Fixed delay in ms; empty attribute uses the global default (3000ms) -->
<chat-widget client:defer="8000"></chat-widget>

<!-- Override interaction events for this element only (space-separated MDN event names) -->
<cart-flyout client:interaction="mouseenter"></cart-flyout>

The attribute value overrides the globally configured default for that element. Other elements are unaffected.

client:defer without a value uses the global default

html
<!-- Uses global defer.delay (default 3000ms) -->
<chat-widget client:defer></chat-widget>

<!-- Uses 0ms delay — loads on next tick -->
<chat-widget client:defer="0"></chat-widget>

An empty client:defer attribute is NOT zero — it falls back to the configured defer.delay (default 3000ms).

client:interaction with no value uses the default events

html
<!-- Uses default events: mouseenter, touchstart, focusin -->
<cart-flyout client:interaction></cart-flyout>

<!-- Uses only mouseenter -->
<cart-flyout client:interaction="mouseenter"></cart-flyout>

An empty client:interaction attribute silently uses the configured default events — no warning is emitted (unlike client:media).

Changing built-in directive defaults globally

ts
// vite.config.ts
shopifyThemeIslands({
  directives: {
    visible: { rootMargin: "0px" },
    defer: { delay: 5000 },
    interaction: { events: ["mouseenter"] },
  },
});

Common Mistakes

HIGH client:media="" skips the media check entirely

Wrong:

html
<mobile-nav client:media=""></mobile-nav>

Correct:

html
<mobile-nav client:media="(max-width: 768px)"></mobile-nav>

An empty client:media value emits a console warning and skips the media check — the island loads immediately. Provide a valid media query string.

Source: src/runtime.ts — if (query === "") branch

HIGH Multiple directives are AND, not OR

Wrong assumption:

html
<!-- Expecting: load when visible OR when media matches -->
<product-recs client:visible client:media="(min-width: 768px)"></product-recs>

Correct understanding:

html
<!-- Loads only when BOTH visible AND media match -->
<product-recs client:visible client:media="(min-width: 768px)"></product-recs>

The runtime awaits each directive sequentially. There is no way to express OR semantics with built-in directives — use a custom directive for that.

Source: src/runtime.ts — loadIsland sequential awaits

MEDIUM client:defer without value ≠ immediate load

Wrong:

html
<!-- Expecting 0ms or immediate load -->
<chat-widget client:defer></chat-widget>

Correct:

html
<!-- Explicit 0ms for immediate load after current call stack -->
<chat-widget client:defer="0"></chat-widget>

client:defer with no value uses the global defer.delay default (3000ms). parseInt("", 10) produces NaN, which the runtime replaces with the configured default.

Source: src/runtime.ts — const ms = Number.isNaN(raw) ? deferDelay : raw

MEDIUM Per-element visible value replaces rootMargin, not adds to it

Wrong:

html
<!-- Expecting 200px (global) + 100px = 300px effective margin -->
<hero-banner client:visible="100px"></hero-banner>

Correct:

html
<!-- "100px" replaces the global rootMargin entirely -->
<hero-banner client:visible="100px"></hero-banner>

The attribute value is passed directly to IntersectionObserver as rootMargin, fully replacing the global default.

Source: src/runtime.ts — await visible(el, visibleAttr || rootMargin, threshold, pendingCancellable)

HIGH Directive attribute typo — island loads without condition

Wrong:

html
<product-form client:visibled></product-form>
<product-form client:Visible></product-form>

Correct:

html
<product-form client:visible></product-form>

Directive attributes are case-sensitive. An unrecognised attribute is silently ignored — the island loads immediately as if no directive were set. No warning is emitted. Check for typos if an island activates earlier than expected.

Source: src/runtime.ts — runtime checks exact attribute names from plugin config

HIGH Agent uses default attribute name when developer has configured a custom one

Wrong:

html
<!-- developer has set visible.attribute: "data:visible" in vite.config.ts -->
<product-form client:visible></product-form>

Correct:

html
<product-form data:visible></product-form>

When directives.visible.attribute (or any directive's attribute option) is overridden in vite.config.ts, all Liquid templates must use the configured name. The default client:* names no longer apply. Always read vite.config.ts to check for overridden attribute names before writing directives in Liquid.

Source: src/index.ts:DirectivesConfig — attribute field per directive; src/runtime.ts reads configured attribute names at runtime